Skip to content

Commit 69c69cc

Browse files
committed
Address PR review hardening feedback
1 parent 18dd53c commit 69c69cc

8 files changed

Lines changed: 390 additions & 40 deletions

File tree

docs/testing/test-run-report.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Agent Testing Run Report
22

3-
Generated: `2026-05-10T06:07:15.3869410+00:00`
3+
Generated: `2026-05-10T09:13:50.5670530+00:00`
44
Gate status: **PASS**
55

66
## Gates
@@ -17,10 +17,10 @@ Gate status: **PASS**
1717

1818
| Scenario | Risk | Status | Trace |
1919
|----------|------|--------|-------|
20-
| approval-required | High | PASS | ../../artifacts/testing/traces/approval-required-d92e073423fa46d6b7642445703ba21b.trace.json |
21-
| basic-tool-call | Low | PASS | ../../artifacts/testing/traces/basic-tool-call-85bf00b5ce654f178528a604baa0d360.trace.json |
22-
| timeout-retry-placeholder | Medium | PASS | ../../artifacts/testing/traces/timeout-retry-placeholder-ef372e2a92834b63b9070d10f0c1628f.trace.json |
23-
| unsafe-tool-blocked | Critical | PASS | ../../artifacts/testing/traces/unsafe-tool-blocked-a6cea282048e4622bfc7ea02b2d664f1.trace.json |
20+
| approval-required | High | PASS | ../../artifacts/testing/traces/approval-required-38827d895786449094bbd28d8b640055.trace.json |
21+
| basic-tool-call | Low | PASS | ../../artifacts/testing/traces/basic-tool-call-01ea2af915aa442ea785c520fc90d869.trace.json |
22+
| timeout-retry-placeholder | Medium | PASS | ../../artifacts/testing/traces/timeout-retry-placeholder-6621dde240b1468a87f98735d6af81cd.trace.json |
23+
| unsafe-tool-blocked | Critical | PASS | ../../artifacts/testing/traces/unsafe-tool-blocked-8554b8c5b4304acfa242c050a835a57a.trace.json |
2424

2525
## Oracle Results
2626

src/SharpClaw.Code.Providers/OpenAiCompatibleProvider.cs

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
using System.Collections.Concurrent;
12
using System.ClientModel;
23
using System.Runtime.CompilerServices;
4+
using System.Security.Cryptography;
5+
using System.Text;
36
using Microsoft.Extensions.AI;
47
using Microsoft.Extensions.Logging;
58
using Microsoft.Extensions.Options;
@@ -23,6 +26,7 @@ public sealed class OpenAiCompatibleProvider(
2326
ILogger<OpenAiCompatibleProvider> logger) : IModelProvider
2427
{
2528
private readonly OpenAiCompatibleProviderOptions _options = options.Value;
29+
private readonly ConcurrentDictionary<OpenAiClientCacheKey, OpenAIClient> _clientCache = new();
2630
internal const string RuntimeProfileMetadataKey = "openai-compatible.profile";
2731

2832
/// <inheritdoc />
@@ -62,7 +66,7 @@ private async IAsyncEnumerable<ProviderEvent> StreamEventsAsync(
6266
var modelId = Internal.ProviderHttpHelpers.ResolveModelOrDefault(
6367
request.Model,
6468
profile?.DefaultChatModel ?? _options.DefaultModel);
65-
var openAiClient = CreateOpenAiClient(profile, resolvedApiKey);
69+
var openAiClient = GetOrCreateOpenAiClient(profile, resolvedApiKey);
6670
var nativeClient = openAiClient.GetChatClient(modelId);
6771
using var chatClient = nativeClient.AsIChatClient();
6872

@@ -95,20 +99,29 @@ private async IAsyncEnumerable<ProviderEvent> StreamEventsAsync(
9599
}
96100
}
97101

98-
private OpenAIClient CreateOpenAiClient(LocalRuntimeProfileOptions? profile, string? resolvedApiKey)
102+
private OpenAIClient GetOrCreateOpenAiClient(LocalRuntimeProfileOptions? profile, string? resolvedApiKey)
99103
{
100-
var openAiOptions = new OpenAIClientOptions();
101104
var normalized = Internal.ProviderHttpHelpers.NormalizeBaseUrl(profile?.BaseUrl ?? _options.BaseUrl);
102-
if (normalized is not null)
103-
{
104-
openAiOptions.Endpoint = new Uri(normalized);
105-
}
106-
107105
var apiKey = profile?.ApiKey ?? resolvedApiKey ?? _options.ApiKey ?? "local-runtime";
108-
var credential = new ApiKeyCredential(apiKey);
109-
return new OpenAIClient(credential, openAiOptions);
106+
var cacheKey = new OpenAiClientCacheKey(normalized, ComputeCredentialFingerprint(apiKey));
107+
return _clientCache.GetOrAdd(
108+
cacheKey,
109+
static (_, state) =>
110+
{
111+
var openAiOptions = new OpenAIClientOptions();
112+
if (state.NormalizedEndpoint is not null)
113+
{
114+
openAiOptions.Endpoint = new Uri(state.NormalizedEndpoint);
115+
}
116+
117+
return new OpenAIClient(new ApiKeyCredential(state.ApiKey), openAiOptions);
118+
},
119+
(NormalizedEndpoint: normalized, ApiKey: apiKey));
110120
}
111121

122+
private static string ComputeCredentialFingerprint(string apiKey)
123+
=> Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(apiKey)));
124+
112125
private static List<Microsoft.Extensions.AI.ChatMessage> BuildChatMessages(ProviderRequest request)
113126
{
114127
var messages = new List<Microsoft.Extensions.AI.ChatMessage>();
@@ -144,4 +157,6 @@ private async Task<ResolvedProviderCredential> ResolveCredentialAsync(Cancellati
144157

145158
return await credentialStore.ResolveAsync(ProviderName, cancellationToken).ConfigureAwait(false);
146159
}
160+
161+
private readonly record struct OpenAiClientCacheKey(string? Endpoint, string CredentialFingerprint);
147162
}

src/SharpClaw.Code.Providers/Services/ProviderCredentialStore.cs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,11 +113,22 @@ private async Task<StoredCredentialDocument> LoadAsync(CancellationToken cancell
113113
var text = await fileSystem.ReadAllTextIfExistsAsync(path, cancellationToken).ConfigureAwait(false);
114114
if (string.IsNullOrWhiteSpace(text))
115115
{
116-
return new StoredCredentialDocument(new Dictionary<string, StoredCredentialEntry>(StringComparer.OrdinalIgnoreCase));
116+
return CreateEmptyDocument();
117117
}
118118

119-
return JsonSerializer.Deserialize<StoredCredentialDocument>(text, JsonOptions)
120-
?? new StoredCredentialDocument(new Dictionary<string, StoredCredentialEntry>(StringComparer.OrdinalIgnoreCase));
119+
try
120+
{
121+
var document = JsonSerializer.Deserialize<StoredCredentialDocument>(text, JsonOptions);
122+
return document?.Providers is null
123+
? CreateEmptyDocument()
124+
: new StoredCredentialDocument(document.Providers
125+
.Where(static pair => pair.Value is not null)
126+
.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.OrdinalIgnoreCase));
127+
}
128+
catch (JsonException)
129+
{
130+
return CreateEmptyDocument();
131+
}
121132
}
122133

123134
private Task SaveAsync(StoredCredentialDocument document, CancellationToken cancellationToken)
@@ -126,6 +137,9 @@ private Task SaveAsync(StoredCredentialDocument document, CancellationToken canc
126137
private string GetPath()
127138
=> pathService.Combine(userProfilePaths.GetUserSharpClawRoot(), CredentialsFileName);
128139

140+
private static StoredCredentialDocument CreateEmptyDocument()
141+
=> new(new Dictionary<string, StoredCredentialEntry>(StringComparer.OrdinalIgnoreCase));
142+
129143
private sealed record StoredCredentialDocument(
130144
Dictionary<string, StoredCredentialEntry> Providers);
131145

src/SharpClaw.Code.Runtime/Prompts/PromptReferenceResolver.cs

Lines changed: 110 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ public sealed partial class PromptReferenceResolver(
2222
{
2323
private const int MaxDirectoryReferenceFiles = 20;
2424
private const int MaxDirectoryReferenceBytes = 200 * 1024;
25+
private const long MaxImageReferenceBytes = 8 * 1024 * 1024;
2526
private static readonly HashSet<string> ImageExtensions = new(StringComparer.OrdinalIgnoreCase)
2627
{
2728
".png", ".jpg", ".jpeg", ".gif", ".webp"
@@ -230,8 +231,35 @@ private static string ToDisplayPath(string workspaceRootFull, string workingDire
230231

231232
if (ImageExtensions.Contains(Path.GetExtension(resolvedFull)))
232233
{
233-
var bytes = await File.ReadAllBytesAsync(resolvedFull, cancellationToken).ConfigureAwait(false);
234+
cancellationToken.ThrowIfCancellationRequested();
235+
var fileInfo = new FileInfo(resolvedFull);
236+
if (!fileInfo.Exists)
237+
{
238+
throw new InvalidOperationException($"Referenced path is missing or unreadable: '{resolvedFull}'.");
239+
}
240+
234241
var mediaType = ResolveMediaType(resolvedFull);
242+
if (fileInfo.Length > MaxImageReferenceBytes)
243+
{
244+
var omittedPlaceholder =
245+
$"[Referenced image omitted: {display} exceeds {FormatBytes(MaxImageReferenceBytes)}]" + Environment.NewLine
246+
+ $"[End referenced image: {display}]";
247+
return (
248+
omittedPlaceholder,
249+
new PromptReference(
250+
PromptReferenceKind.Image,
251+
rawToken,
252+
pathPart,
253+
resolvedFull,
254+
display,
255+
outsideWorkspace,
256+
omittedPlaceholder,
257+
MediaType: mediaType,
258+
IncludedEntryCount: 0),
259+
null);
260+
}
261+
262+
var bytes = await File.ReadAllBytesAsync(resolvedFull, cancellationToken).ConfigureAwait(false);
235263
var placeholder =
236264
$"[Referenced image: {display} ({mediaType})]" + Environment.NewLine
237265
+ $"[End referenced image: {display}]";
@@ -287,14 +315,9 @@ private static string ToDisplayPath(string workspaceRootFull, string workingDire
287315
string display,
288316
CancellationToken cancellationToken)
289317
{
290-
var files = Directory.EnumerateFiles(directoryPath, "*", SearchOption.AllDirectories)
291-
.Where(static path => !ShouldSkipPath(path))
292-
.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase)
293-
.ToArray();
294-
295318
var included = new List<(string RelativePath, string Content)>();
296319
var totalBytes = 0;
297-
foreach (var file in files)
320+
foreach (var file in EnumerateReferenceFiles(directoryPath, cancellationToken))
298321
{
299322
cancellationToken.ThrowIfCancellationRequested();
300323
if (included.Count >= MaxDirectoryReferenceFiles)
@@ -307,12 +330,28 @@ private static string ToDisplayPath(string workspaceRootFull, string workingDire
307330
continue;
308331
}
309332

333+
long fileLength;
334+
try
335+
{
336+
fileLength = new FileInfo(file).Length;
337+
}
338+
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
339+
{
340+
continue;
341+
}
342+
343+
var remainingBytes = MaxDirectoryReferenceBytes - totalBytes;
344+
if (fileLength > remainingBytes)
345+
{
346+
break;
347+
}
348+
310349
string text;
311350
try
312351
{
313352
text = await File.ReadAllTextAsync(file, cancellationToken).ConfigureAwait(false);
314353
}
315-
catch
354+
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or DecoderFallbackException)
316355
{
317356
continue;
318357
}
@@ -356,10 +395,73 @@ private static string ToDisplayPath(string workspaceRootFull, string workingDire
356395
return (builder.ToString(), included.Count);
357396
}
358397

398+
private static IEnumerable<string> EnumerateReferenceFiles(string directoryPath, CancellationToken cancellationToken)
399+
{
400+
var pending = new Stack<string>();
401+
pending.Push(directoryPath);
402+
403+
while (pending.Count > 0)
404+
{
405+
cancellationToken.ThrowIfCancellationRequested();
406+
var current = pending.Pop();
407+
foreach (var file in EnumerateSortedFiles(current))
408+
{
409+
cancellationToken.ThrowIfCancellationRequested();
410+
if (!ShouldSkipPath(file))
411+
{
412+
yield return file;
413+
}
414+
}
415+
416+
var directories = EnumerateSortedDirectories(current);
417+
for (var i = directories.Length - 1; i >= 0; i--)
418+
{
419+
cancellationToken.ThrowIfCancellationRequested();
420+
if (!ShouldSkipPath(directories[i]))
421+
{
422+
pending.Push(directories[i]);
423+
}
424+
}
425+
}
426+
}
427+
428+
private static string[] EnumerateSortedFiles(string directoryPath)
429+
{
430+
try
431+
{
432+
return Directory.EnumerateFiles(directoryPath, "*", SearchOption.TopDirectoryOnly)
433+
.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase)
434+
.ToArray();
435+
}
436+
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
437+
{
438+
return [];
439+
}
440+
}
441+
442+
private static string[] EnumerateSortedDirectories(string directoryPath)
443+
{
444+
try
445+
{
446+
return Directory.EnumerateDirectories(directoryPath, "*", SearchOption.TopDirectoryOnly)
447+
.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase)
448+
.ToArray();
449+
}
450+
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
451+
{
452+
return [];
453+
}
454+
}
455+
359456
private static bool ShouldSkipPath(string path)
360457
=> path.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
361458
.Any(static segment => segment is ".git" or ".sharpclaw" or "bin" or "obj");
362459

460+
private static string FormatBytes(long bytes)
461+
=> bytes >= 1024 * 1024
462+
? $"{bytes / (1024 * 1024)} MiB"
463+
: $"{bytes / 1024} KiB";
464+
363465
private static string ResolveMediaType(string path)
364466
=> Path.GetExtension(path).ToLowerInvariant() switch
365467
{

src/SharpClaw.Code.Runtime/Server/WorkspaceHttpServer.cs

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,13 @@ public async Task RunAsync(
5252
var effectivePort = port is > 0 ? port.Value : config.Document.Server?.Port ?? 7345;
5353
var prefix = $"http://{effectiveHost}:{effectivePort}/";
5454

55-
using var listener = new HttpListener();
56-
listener.Prefixes.Add(prefix);
57-
listener.Start();
58-
logger.LogInformation("SharpClaw server listening on {Prefix}", prefix);
59-
55+
var listener = new HttpListener();
6056
try
6157
{
58+
listener.Prefixes.Add(prefix);
59+
listener.Start();
60+
logger.LogInformation("SharpClaw server listening on {Prefix}", prefix);
61+
6262
while (!cancellationToken.IsCancellationRequested)
6363
{
6464
HttpListenerContext httpContext;
@@ -82,10 +82,21 @@ public async Task RunAsync(
8282
}
8383
finally
8484
{
85-
if (listener.IsListening)
86-
{
87-
listener.Stop();
88-
}
85+
CloseListener(listener);
86+
}
87+
}
88+
89+
private static void CloseListener(HttpListener listener)
90+
{
91+
try
92+
{
93+
listener.Close();
94+
}
95+
catch (HttpListenerException)
96+
{
97+
}
98+
catch (ObjectDisposedException)
99+
{
89100
}
90101
}
91102

tests/SharpClaw.Code.IntegrationTests/Runtime/ApprovalAuthIntegrationTests.cs

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -279,10 +279,7 @@ public string CreateToken(string subjectId, string tenantId)
279279
public async ValueTask DisposeAsync()
280280
{
281281
cancellationTokenSource.Cancel();
282-
if (listener.IsListening)
283-
{
284-
listener.Stop();
285-
}
282+
CloseListener();
286283

287284
try
288285
{
@@ -294,12 +291,29 @@ public async ValueTask DisposeAsync()
294291
catch (HttpListenerException)
295292
{
296293
}
294+
catch (ObjectDisposedException)
295+
{
296+
}
297297

298-
listener.Close();
298+
CloseListener();
299299
rsa.Dispose();
300300
cancellationTokenSource.Dispose();
301301
}
302302

303+
private void CloseListener()
304+
{
305+
try
306+
{
307+
listener.Close();
308+
}
309+
catch (HttpListenerException)
310+
{
311+
}
312+
catch (ObjectDisposedException)
313+
{
314+
}
315+
}
316+
303317
private async Task RunAsync(CancellationToken cancellationToken)
304318
{
305319
while (!cancellationToken.IsCancellationRequested)
@@ -317,6 +331,10 @@ private async Task RunAsync(CancellationToken cancellationToken)
317331
{
318332
break;
319333
}
334+
catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested)
335+
{
336+
break;
337+
}
320338

321339
_ = Task.Run(() => HandleAsync(context, cancellationToken), CancellationToken.None);
322340
}

0 commit comments

Comments
 (0)