Skip to content

Commit 518f50d

Browse files
Merge remote-tracking branch 'origin/main' into agents/ai-agent-security-review
# Conflicts: # EssentialCSharp.Chat.Shared/Services/AIChatService.cs Co-authored-by: BenjaminMichaelis <22186029+BenjaminMichaelis@users.noreply.github.com>
2 parents ecea989 + f52ca45 commit 518f50d

17 files changed

Lines changed: 326 additions & 267 deletions

File tree

.github/workflows/Build-Test-And-Deploy.yml

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -63,23 +63,6 @@ jobs:
6363
- name: Set up Docker Buildx
6464
uses: docker/setup-buildx-action@v4
6565

66-
- name: Generate NuGet auth config for Docker build
67-
if: github.event_name != 'pull_request' && github.event_name != 'merge_group'
68-
env:
69-
NUGET_AUTH_TOKEN: ${{ secrets.AZURE_DEVOPS_PAT }}
70-
run: |
71-
cat > /tmp/nuget-auth.config << EOF
72-
<?xml version="1.0" encoding="utf-8"?>
73-
<configuration>
74-
<packageSourceCredentials>
75-
<EssentialCSharp>
76-
<add key="Username" value="docker" />
77-
<add key="ClearTextPassword" value="${NUGET_AUTH_TOKEN}" />
78-
</EssentialCSharp>
79-
</packageSourceCredentials>
80-
</configuration>
81-
EOF
82-
8366
# Build but no push with a PR
8467
- name: Docker build (no push)
8568
if: github.event_name == 'pull_request' || github.event_name == 'merge_group'
@@ -99,7 +82,7 @@ jobs:
9982
file: ./EssentialCSharp.Web/Dockerfile
10083
context: .
10184
secrets: |
102-
"id=nugetconfig,src=/tmp/nuget-auth.config"
85+
"nuget_pat=${{ secrets.AZURE_DEVOPS_PAT }}"
10386
outputs: type=docker,dest=${{ github.workspace }}/essentialcsharpwebimage.tar
10487
cache-from: type=gha
10588
cache-to: type=gha,mode=max
@@ -109,10 +92,6 @@ jobs:
10992
name: essentialcsharpwebimage
11093
path: ${{ github.workspace }}/essentialcsharpwebimage.tar
11194

112-
- name: Clean up NuGet auth config
113-
if: always()
114-
run: rm -f /tmp/nuget-auth.config
115-
11695
deploy-development:
11796
if: github.event_name != 'pull_request_target' && github.event_name != 'pull_request'
11897
runs-on: ubuntu-latest

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ TestResults/
1010
~$*.do[ct]x
1111
~*.tmp
1212
~$*.dotm
13+
.playwright-mcp/
1314

1415
# Files to keep (primarily book content)
1516
!.gitignore

Directory.Packages.props

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,10 @@
44
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
55
<ToolingPackagesVersion>1.1.1.19025</ToolingPackagesVersion>
66
<AccessToNugetFeed Condition="'$(AccessToNugetFeed)' == ''">false</AccessToNugetFeed>
7-
<RestoreSources>
8-
https://api.nuget.org/v3/index.json;
9-
</RestoreSources>
10-
<RestoreSources Condition="$(AccessToNugetFeed)">
11-
$(RestoreSources);
12-
https://pkgs.dev.azure.com/intelliTect/_packaging/EssentialCSharp/nuget/v3/index.json;
13-
</RestoreSources>
7+
<!-- Disable NuGet vulnerability audit when the private feed is unavailable (e.g. CI without credentials).
8+
NuGet audit queries all sources in nuget.config regardless of RestoreSources, causing NU1900 which is
9+
escalated to an error by TreatWarningsAsErrors. -->
10+
<NuGetAudit Condition="'$(AccessToNugetFeed)' != 'true'">false</NuGetAudit>
1411
</PropertyGroup>
1512
<PropertyGroup>
1613
<SemanticKernelVersion>1.72.0</SemanticKernelVersion>

EssentialCSharp.Chat.Shared/Services/AIChatService.cs

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,9 @@ public AIChatService(IOptions<AIOptions> options, AISearchService searchService,
6363
string? endUserId = null,
6464
CancellationToken cancellationToken = default)
6565
{
66-
var responseOptions = await CreateResponseOptionsAsync(previousResponseId, tools, reasoningEffortLevel, mcpClient: mcpClient, endUserId: endUserId, cancellationToken: cancellationToken);
66+
var responseOptions = await CreateResponseOptionsAsync(systemPrompt, previousResponseId, tools, reasoningEffortLevel, mcpClient: mcpClient, endUserId: endUserId, cancellationToken: cancellationToken);
6767
var enrichedPrompt = await EnrichPromptWithContext(prompt, enableContextualSearch, cancellationToken);
68-
return await GetChatCompletionCore(enrichedPrompt, responseOptions, systemPrompt, mcpClient, endUserId, cancellationToken);
68+
return await GetChatCompletionCore(enrichedPrompt, responseOptions, mcpClient, endUserId, cancellationToken);
6969
}
7070

7171
/// <summary>
@@ -94,17 +94,12 @@ public AIChatService(IOptions<AIOptions> options, AISearchService searchService,
9494
string? endUserId = null,
9595
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
9696
{
97-
var responseOptions = await CreateResponseOptionsAsync(previousResponseId, tools, reasoningEffortLevel, mcpClient: mcpClient, endUserId: endUserId, cancellationToken: cancellationToken);
97+
var responseOptions = await CreateResponseOptionsAsync(systemPrompt, previousResponseId, tools, reasoningEffortLevel, mcpClient: mcpClient, endUserId: endUserId, cancellationToken: cancellationToken);
9898
var enrichedPrompt = await EnrichPromptWithContext(prompt, enableContextualSearch, cancellationToken);
9999

100-
// Construct the user input with system context if provided
101-
var systemContext = !string.IsNullOrWhiteSpace(systemPrompt) ? systemPrompt : _Options.SystemPrompt;
102-
103100
// Create the streaming response using the Responses API
104101
#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
105-
List<ResponseItem> responseItems = systemContext is not null
106-
? [ResponseItem.CreateSystemMessageItem(systemContext), ResponseItem.CreateUserMessageItem(enrichedPrompt)]
107-
: [ResponseItem.CreateUserMessageItem(enrichedPrompt)];
102+
List<ResponseItem> responseItems = [ResponseItem.CreateUserMessageItem(enrichedPrompt)];
108103
#pragma warning restore OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
109104
var streamingUpdates = _ResponseClient.CreateResponseStreamingAsync(
110105
responseItems,
@@ -327,6 +322,7 @@ private static string SanitizeForXmlContext(string? input) =>
327322
/// </summary>
328323
#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
329324
private async Task<ResponseCreationOptions> CreateResponseOptionsAsync(
325+
string? systemPrompt = null,
330326
string? previousResponseId = null,
331327
IEnumerable<ResponseTool>? tools = null,
332328
ResponseReasoningEffortLevel? reasoningEffortLevel = null,
@@ -338,6 +334,14 @@ private async Task<ResponseCreationOptions> CreateResponseOptionsAsync(
338334
var options = new ResponseCreationOptions();
339335
#pragma warning restore OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
340336

337+
// Set the system prompt via Instructions — this is stateless across turns when using previous_response_id,
338+
// preventing accumulation of system messages in the conversation context.
339+
var resolvedSystemPrompt = !string.IsNullOrWhiteSpace(systemPrompt) ? systemPrompt : _Options.SystemPrompt;
340+
if (!string.IsNullOrWhiteSpace(resolvedSystemPrompt))
341+
{
342+
options.Instructions = resolvedSystemPrompt;
343+
}
344+
341345
// Add conversation context if available
342346
if (!string.IsNullOrEmpty(previousResponseId))
343347
{
@@ -401,18 +405,13 @@ private async Task<ResponseCreationOptions> CreateResponseOptionsAsync(
401405
#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
402406
ResponseCreationOptions responseOptions,
403407
#pragma warning restore OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
404-
string? systemPrompt = null,
405408
McpClient? mcpClient = null,
406409
string? endUserId = null,
407410
CancellationToken cancellationToken = default)
408411
{
409-
// Construct the user input with system context if provided
410-
var systemContext = !string.IsNullOrWhiteSpace(systemPrompt) ? systemPrompt : _Options.SystemPrompt;
411-
412+
// Create the response using the Responses API
412413
#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
413-
List<ResponseItem> responseItems = systemContext is not null
414-
? [ResponseItem.CreateSystemMessageItem(systemContext), ResponseItem.CreateUserMessageItem(prompt)]
415-
: [ResponseItem.CreateUserMessageItem(prompt)];
414+
List<ResponseItem> responseItems = [ResponseItem.CreateUserMessageItem(prompt)];
416415
#pragma warning restore OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
417416

418417
const int MaxToolCallIterations = 10;

EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
using System.IO;
21
using System.Globalization;
32
using DotnetSitemapGenerator;
43
using EssentialCSharp.Web.Helpers;
@@ -70,14 +69,12 @@ await Assert.That(() => SitemapXmlHelpers.EnsureSitemapHealthy(siteMappings))
7069
public async Task GenerateSitemapXml_DoesNotIncludeIdentityRoutes()
7170
{
7271
// Arrange
73-
var tempDir = Directory.CreateTempSubdirectory("SitemapTest_");
7472
var siteMappings = new List<SiteMapping> { CreateSiteMapping(1, 1, true) };
7573
var baseUrl = "https://test.example.com/";
7674

7775
// Act & Assert
7876
var routeConfigurationService = _Factory.Services.GetRequiredService<IRouteConfigurationService>();
7977
SitemapXmlHelpers.GenerateSitemapXml(
80-
tempDir,
8178
siteMappings,
8279
routeConfigurationService,
8380
baseUrl,
@@ -98,14 +95,12 @@ public async Task GenerateSitemapXml_DoesNotIncludeIdentityRoutes()
9895
public async Task GenerateSitemapXml_IncludesBaseUrl()
9996
{
10097
// Arrange
101-
var tempDir = Directory.CreateTempSubdirectory("SitemapTest_");
10298
var siteMappings = new List<SiteMapping>();
10399
var baseUrl = "https://test.example.com/";
104100

105101
// Act & Assert
106102
var routeConfigurationService = _Factory.Services.GetRequiredService<IRouteConfigurationService>();
107103
SitemapXmlHelpers.GenerateSitemapXml(
108-
tempDir,
109104
siteMappings,
110105
routeConfigurationService,
111106
baseUrl,
@@ -126,7 +121,6 @@ public async Task GenerateSitemapXml_IncludesBaseUrl()
126121
public async Task GenerateSitemapXml_IncludesSiteMappingsMarkedForXml()
127122
{
128123
// Arrange
129-
var tempDir = Directory.CreateTempSubdirectory("SitemapTest_");
130124
var baseUrl = "https://test.example.com/";
131125

132126
var siteMappings = new List<SiteMapping>
@@ -139,7 +133,6 @@ public async Task GenerateSitemapXml_IncludesSiteMappingsMarkedForXml()
139133
// Act & Assert
140134
var routeConfigurationService = _Factory.Services.GetRequiredService<IRouteConfigurationService>();
141135
SitemapXmlHelpers.GenerateSitemapXml(
142-
tempDir,
143136
siteMappings,
144137
routeConfigurationService,
145138
baseUrl,
@@ -156,14 +149,12 @@ public async Task GenerateSitemapXml_IncludesSiteMappingsMarkedForXml()
156149
public async Task GenerateSitemapXml_DoesNotIncludeIndexRoutes()
157150
{
158151
// Arrange
159-
var tempDir = Directory.CreateTempSubdirectory("SitemapTest_");
160152
var siteMappings = new List<SiteMapping>();
161153
var baseUrl = "https://test.example.com/";
162154

163155
// Act & Assert
164156
var routeConfigurationService = _Factory.Services.GetRequiredService<IRouteConfigurationService>();
165157
SitemapXmlHelpers.GenerateSitemapXml(
166-
tempDir,
167158
siteMappings,
168159
routeConfigurationService,
169160
baseUrl,
@@ -179,14 +170,12 @@ public async Task GenerateSitemapXml_DoesNotIncludeIndexRoutes()
179170
public async Task GenerateSitemapXml_DoesNotIncludeErrorRoutes()
180171
{
181172
// Arrange
182-
var tempDir = Directory.CreateTempSubdirectory("SitemapTest_");
183173
var siteMappings = new List<SiteMapping>();
184174
var baseUrl = "https://test.example.com/";
185175

186176
// Act & Assert
187177
var routeConfigurationService = _Factory.Services.GetRequiredService<IRouteConfigurationService>();
188178
SitemapXmlHelpers.GenerateSitemapXml(
189-
tempDir,
190179
siteMappings,
191180
routeConfigurationService,
192181
baseUrl,
@@ -198,11 +187,31 @@ public async Task GenerateSitemapXml_DoesNotIncludeErrorRoutes()
198187
await Assert.That(allUrls).DoesNotContain(url => url.Contains("/Error", StringComparison.OrdinalIgnoreCase));
199188
}
200189

190+
[Test]
191+
public async Task GenerateSitemapXml_DoesNotIncludeSitemapRoute()
192+
{
193+
// Arrange
194+
var siteMappings = new List<SiteMapping>();
195+
var baseUrl = "https://test.example.com/";
196+
197+
// Act
198+
var routeConfigurationService = _Factory.Services.GetRequiredService<IRouteConfigurationService>();
199+
SitemapXmlHelpers.GenerateSitemapXml(
200+
siteMappings,
201+
routeConfigurationService,
202+
baseUrl,
203+
out var nodes);
204+
205+
var allUrls = nodes.Select(n => n.Url).ToList();
206+
207+
// /sitemap.xml should not list itself
208+
await Assert.That(allUrls).DoesNotContain(url => url.EndsWith("/sitemap.xml", StringComparison.OrdinalIgnoreCase));
209+
}
210+
201211
[Test]
202212
public async Task GenerateSitemapXml_UsesLastModifiedDateFromSiteMapping()
203213
{
204214
// Arrange
205-
var tempDir = Directory.CreateTempSubdirectory("SitemapTest_");
206215
var baseUrl = "https://test.example.com/";
207216
var specificLastModified = new DateTime(2023, 5, 15, 10, 30, 0, DateTimeKind.Utc);
208217

@@ -214,7 +223,6 @@ public async Task GenerateSitemapXml_UsesLastModifiedDateFromSiteMapping()
214223
// Act
215224
var routeConfigurationService = _Factory.Services.GetRequiredService<IRouteConfigurationService>();
216225
SitemapXmlHelpers.GenerateSitemapXml(
217-
tempDir,
218226
siteMappings,
219227
routeConfigurationService,
220228
baseUrl,
@@ -225,6 +233,29 @@ public async Task GenerateSitemapXml_UsesLastModifiedDateFromSiteMapping()
225233
await Assert.That(siteMappingNode.LastModificationDate).IsEqualTo(specificLastModified);
226234
}
227235

236+
[Test]
237+
public async Task GenerateSitemapXml_DoesNotSetLastModifiedDateWhenSiteMappingDateIsMissing()
238+
{
239+
// Arrange
240+
var baseUrl = "https://test.example.com/";
241+
var siteMappings = new List<SiteMapping>
242+
{
243+
CreateSiteMapping(1, 1, true, "test-page-1")
244+
};
245+
246+
// Act
247+
var routeConfigurationService = _Factory.Services.GetRequiredService<IRouteConfigurationService>();
248+
SitemapXmlHelpers.GenerateSitemapXml(
249+
siteMappings,
250+
routeConfigurationService,
251+
baseUrl,
252+
out var nodes);
253+
254+
// Assert
255+
var siteMappingNode = nodes.First(node => node.Url.Contains("test-page-1"));
256+
await Assert.That(siteMappingNode.LastModificationDate).IsNull();
257+
}
258+
228259
private static SiteMapping CreateSiteMapping(
229260
int chapterNumber,
230261
int pageNumber,

EssentialCSharp.Web/Controllers/BaseController.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ protected BaseController(IRouteConfigurationService routeConfigurationService, I
1616
_HttpContextAccessor = httpContextAccessor;
1717
}
1818

19+
protected IRouteConfigurationService RouteConfigurationService => _RouteConfigurationService;
20+
1921
public override void OnActionExecuting(ActionExecutingContext context)
2022
{
2123
// Automatically add static routes to all views

EssentialCSharp.Web/Controllers/ChatController.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ namespace EssentialCSharp.Web.Controllers;
1212
[Route("api/[controller]")]
1313
[Authorize]
1414
[EnableRateLimiting("ChatEndpoint")]
15+
[IgnoreAntiforgeryToken]
1516
public partial class ChatController : ControllerBase
1617
{
1718
private readonly AIChatService _AiChatService;

EssentialCSharp.Web/Controllers/HomeController.cs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
1+
using DotnetSitemapGenerator;
12
using EssentialCSharp.Web.Extensions;
3+
using EssentialCSharp.Web.Helpers;
24
using EssentialCSharp.Web.Models;
35
using EssentialCSharp.Web.Services;
46
using HtmlAgilityPack;
57
using Microsoft.AspNetCore.Mvc;
8+
using Microsoft.AspNetCore.OutputCaching;
69
using Microsoft.AspNetCore.RateLimiting;
710
using Microsoft.CodeAnalysis;
11+
using Microsoft.Extensions.Options;
812

913
namespace EssentialCSharp.Web.Controllers;
1014

11-
public class HomeController(ILogger<HomeController> logger, IWebHostEnvironment hostingEnvironment, ISiteMappingService siteMappingService, IHttpContextAccessor httpContextAccessor, IRouteConfigurationService routeConfigurationService) : BaseController(routeConfigurationService, httpContextAccessor)
15+
public class HomeController(ILogger<HomeController> logger, IWebHostEnvironment hostingEnvironment, ISiteMappingService siteMappingService, IHttpContextAccessor httpContextAccessor, IRouteConfigurationService routeConfigurationService, IOptions<SiteSettings> siteSettings) : BaseController(routeConfigurationService, httpContextAccessor)
1216
{
1317
[EnableRateLimiting("content")]
1418
public IActionResult Index()
@@ -86,6 +90,15 @@ public IActionResult Guidelines()
8690
return View();
8791
}
8892

93+
[Route("/sitemap.xml")]
94+
[OutputCache(Duration = 3600)]
95+
[EnableRateLimiting("content")]
96+
public IActionResult SitemapXml()
97+
{
98+
SitemapXmlHelpers.GenerateSitemapXml(siteMappingService.SiteMappings, RouteConfigurationService, siteSettings.Value.BaseUrl, out var nodes);
99+
return new SitemapProvider().CreateSitemap(new SitemapModel(nodes));
100+
}
101+
89102
private string FlipPage(int currentChapter, int currentPage, bool next)
90103
{
91104
if (siteMappingService.SiteMappings.Count == 0)

EssentialCSharp.Web/Controllers/McpTokenController.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ namespace EssentialCSharp.Web.Controllers;
99
[Route("api/[controller]")]
1010
[Authorize]
1111
[ResponseCache(Location = ResponseCacheLocation.None, NoStore = true)]
12+
[IgnoreAntiforgeryToken]
1213
public class McpTokenController(McpApiTokenService tokenService) : ControllerBase
1314
{
1415
public record CreateTokenRequest(string? Name, DateOnly? ExpiresOn = null);

EssentialCSharp.Web/Dockerfile

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
#syntax=docker/dockerfile:1.2
1+
# syntax=docker/dockerfile:1.4
22

3-
FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled AS base
3+
FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled-extra AS base
44
WORKDIR /app
55
EXPOSE 8080
66
EXPOSE 8081
@@ -18,15 +18,19 @@ ENV ACCESS_TO_NUGET_FEED=$ACCESS_TO_NUGET_FEED
1818
WORKDIR /src
1919
COPY . .
2020
COPY --from=frontend-build /frontend/EssentialCSharp.Web/wwwroot/dist ./EssentialCSharp.Web/wwwroot/dist
21-
RUN --mount=type=secret,id=nugetconfig,required=false \
22-
if [ "$ACCESS_TO_NUGET_FEED" = "true" ] && [ -f /run/secrets/nugetconfig ]; then \
23-
mkdir -p ~/.nuget/config && \
24-
cp /run/secrets/nugetconfig ~/.nuget/config/credentials.config; \
21+
RUN --mount=type=secret,id=nuget_pat,required=false \
22+
if [ "$ACCESS_TO_NUGET_FEED" = "true" ] && [ ! -s /run/secrets/nuget_pat ]; then \
23+
echo "ERROR: ACCESS_TO_NUGET_FEED=true but nuget_pat secret is missing or empty" >&2; exit 1; \
24+
fi && \
25+
if [ "$ACCESS_TO_NUGET_FEED" = "true" ]; then \
26+
mkdir -p /root/.nuget/NuGet && \
27+
printf '<?xml version="1.0" encoding="utf-8"?>\n<configuration>\n <packageSourceCredentials>\n <EssentialCSharp>\n <add key="Username" value="az" />\n <add key="ClearTextPassword" value="%s" />\n </EssentialCSharp>\n </packageSourceCredentials>\n</configuration>\n' \
28+
"$(cat /run/secrets/nuget_pat)" > /root/.nuget/NuGet/NuGet.Config; \
2529
fi && \
2630
dotnet restore "EssentialCSharp.Web.slnx" -p:AccessToNugetFeed=$ACCESS_TO_NUGET_FEED && \
2731
dotnet build "EssentialCSharp.Web.slnx" -c Release --no-restore -p:AccessToNugetFeed=$ACCESS_TO_NUGET_FEED -p:ReleaseDateAttribute=True -p:SkipFrontendBuild=true && \
2832
dotnet publish "EssentialCSharp.Web.slnx" -c Release -p:PublishDir=/app/publish -p:UseAppHost=false -p:SkipFrontendBuild=true --no-build && \
29-
rm -f ~/.nuget/config/credentials.config
33+
rm -f /root/.nuget/NuGet/NuGet.Config
3034

3135
FROM base AS final
3236
WORKDIR /app

0 commit comments

Comments
 (0)