diff --git a/.ai/mcp/mcp.json b/.ai/mcp/mcp.json
new file mode 100644
index 00000000..e69de29b
diff --git a/.gitignore b/.gitignore
index cd904db3..272a2eed 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,8 +23,12 @@ dist/
.reviews/
.review/
-# Agent memory
+# AI agent artifacts
+.ai/
.claude/agent-memory/
+# Private keys — never commit signing keys or credentials
+*.key
+
# Svelte/Node frontend
**/node_modules/
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
index 7032e9b8..d71c1b3a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -82,12 +82,11 @@ Business logic uses vertical slice architecture with `Immediate.Handlers` (sourc
```
Features/
- Chat/ (4 handlers — SendMessage, BuildChatRequest, ProcessToolCalls, CompactSession)
- Session/ (5 handlers — Load, Save, Clear, List, Prune)
- Cost/ (3 handlers — CheckBudget, RecordUsage, GetSummary)
- Memory/ (5 handlers — Store, Search, Recall, ExtractFacts, Decay)
- Tools/ (1 handler — ExecuteTool)
- Behaviors/ (pipeline behaviors — validation, logging)
+ Chat/ (4 handlers — ApplySecurityGuards, SanitizeReply, BuildChatRequest, RouteModel)
+ Session/ (4 handlers — Load, Save, Clear, Prune)
+ Cost/ (3 handlers — CheckBudget, RecordUsage, GetCostSummary)
+ Memory/ (5 handlers — WriteMemory, ClearMemory, ExtractFacts, SearchMemory, GetMemoryContext)
+ Behaviors/ (pipeline behaviors — authorization, logging)
```
Generated registration methods: `AddclawsharpHandlers()` / `AddclawsharpBehaviors()` (lowercase 'c' — uses raw assembly name). Handler lifetime is `ServiceLifetime.Singleton`.
diff --git a/compose.yaml b/compose.yaml
index 03d21507..4ec9300d 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -56,7 +56,16 @@ services:
tmpfs:
- /tmp # .NET runtime temp files
- /var/tmp # some system libs expect this
- network_mode: host
+ ports:
+ - "127.0.0.1:3001:3001"
+ extra_hosts:
+ - "host.docker.internal:host-gateway"
+ healthcheck:
+ test: ["CMD-SHELL", "dotnet /app/clawsharp.dll doctor 2>/dev/null || exit 1"]
+ interval: 30s
+ timeout: 5s
+ start_period: 15s
+ retries: 3
volumes:
# Persists config.json, sessions, memory, skills, and the .secret_key file.
# The .secret_key file is only used when neither CLAWSHARP_SECRET_KEY env var
@@ -80,18 +89,18 @@ services:
CLAWSHARP__channels__web__enabled: "true"
CLAWSHARP__channels__web__webHost: "0.0.0.0"
CLAWSHARP__channels__web__webPort: "3001"
- CLAWSHARP__channels__web__pairingToken: "test-token-123"
+ CLAWSHARP__channels__web__pairingToken: "${CLAWSHARP_WEB_PAIRING_TOKEN:?Set CLAWSHARP_WEB_PAIRING_TOKEN in .env}"
# ── PostgreSQL memory backend ──────────────────────────────────────────
CLAWSHARP__memory__backend: postgres
- CLAWSHARP__memory__connectionString: "Host=127.0.0.1;Database=clawsharp;Username=clawsharp;Password=${POSTGRES_PASSWORD}"
+ CLAWSHARP__memory__connectionString: "Host=postgres;Database=clawsharp;Username=clawsharp;Password=${POSTGRES_PASSWORD}"
# ── Analytics (interactions) → PostgreSQL ──────────────────────────────
CLAWSHARP__analytics__enabled: "true"
CLAWSHARP__analytics__backend: postgres
- # ── LM Studio (host network — use localhost) ───────────────────────────
- CLAWSHARP__providers__lmstudio__baseUrl: "http://127.0.0.1:1234"
+ # ── LM Studio (via host.docker.internal) ────────────────────────────────
+ CLAWSHARP__providers__lmstudio__baseUrl: "http://host.docker.internal:1234"
CLAWSHARP__agents__defaults__model: "qwen/qwen3.5-9b"
# ── Optional: inline config overrides (no file needed) ──────────────────
diff --git a/nuget.config b/nuget.config
new file mode 100644
index 00000000..765346e5
--- /dev/null
+++ b/nuget.config
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/src/clawsharp-sign/Program.cs b/src/clawsharp-sign/Program.cs
index 6a9f2aa9..c04703f4 100644
--- a/src/clawsharp-sign/Program.cs
+++ b/src/clawsharp-sign/Program.cs
@@ -118,14 +118,15 @@ private static int Sign(ReadOnlySpan args)
// Derive package name from directory name or primary plugin DLL
var package = DerivePackageName(pluginDir, dllFiles);
- // Build manifest without signature for signing
+ var timestamp = DateTimeOffset.UtcNow.ToString("O");
+
+ // Build manifest without signature for signing (canonical payload — no timestamp)
var manifestData = new ManifestData
{
+ Files = new SortedDictionary(files, StringComparer.Ordinal),
+ KeyId = keyId,
Package = package,
Version = version,
- KeyId = keyId,
- Timestamp = DateTimeOffset.UtcNow.ToString("O"),
- Files = new SortedDictionary(files, StringComparer.Ordinal),
};
// Canonical JSON: sorted keys, no whitespace
@@ -141,7 +142,7 @@ private static int Sign(ReadOnlySpan args)
Package = manifestData.Package,
Version = manifestData.Version,
KeyId = manifestData.KeyId,
- Timestamp = manifestData.Timestamp,
+ Timestamp = timestamp,
Files = manifestData.Files,
Signature = signatureBase64,
};
@@ -194,13 +195,13 @@ public static int Verify(ReadOnlySpan args)
}
// Step 1: Verify signature over canonical manifest payload (D-30: signature first)
+ // Canonical payload excludes timestamp — must match signer's ManifestData shape
var manifestData = new ManifestData
{
+ Files = signedManifest.Files,
+ KeyId = signedManifest.KeyId,
Package = signedManifest.Package,
Version = signedManifest.Version,
- KeyId = signedManifest.KeyId,
- Timestamp = signedManifest.Timestamp,
- Files = signedManifest.Files,
};
var canonicalBytes = JsonSerializer.SerializeToUtf8Bytes(manifestData, ManifestJsonContext.Default.ManifestData);
@@ -307,23 +308,26 @@ Verify a signed plugin directory against a public key.
// ── JSON DTOs ───────────────────────────────────────────────────────────
-/// Manifest data without signature — the canonical payload that gets signed.
+///
+/// Manifest data without signature — the canonical payload that gets signed.
+/// Properties are in alphabetical order by JSON key to match the verifier's
+/// SortedDictionary-based canonical payload (STJ source-gen serializes
+/// in declaration order). Timestamp is excluded from the signed payload —
+/// it is metadata in the full only.
+///
internal sealed class ManifestData
{
- [JsonPropertyName("package")]
- public string Package { get; init; } = "";
-
- [JsonPropertyName("version")]
- public string Version { get; init; } = "";
+ [JsonPropertyName("files")]
+ public SortedDictionary Files { get; init; } = new(StringComparer.Ordinal);
[JsonPropertyName("keyId")]
public string KeyId { get; init; } = "";
- [JsonPropertyName("timestamp")]
- public string Timestamp { get; init; } = "";
+ [JsonPropertyName("package")]
+ public string Package { get; init; } = "";
- [JsonPropertyName("files")]
- public SortedDictionary Files { get; init; } = new(StringComparer.Ordinal);
+ [JsonPropertyName("version")]
+ public string Version { get; init; } = "";
}
/// Full manifest with signature — written to plugin.manifest.json.
diff --git a/src/clawsharp-web/src/lib/markdown.ts b/src/clawsharp-web/src/lib/markdown.ts
index 64df431c..3ea4804a 100644
--- a/src/clawsharp-web/src/lib/markdown.ts
+++ b/src/clawsharp-web/src/lib/markdown.ts
@@ -12,6 +12,15 @@ function escapeHtml(s: string): string {
.replace(/>/g, '>');
}
+function isSafeUrl(url: string): boolean {
+ try {
+ const parsed = new URL(url, window.location.origin);
+ return ['http:', 'https:', 'mailto:'].includes(parsed.protocol);
+ } catch {
+ return false;
+ }
+}
+
function inlineMarkdown(s: string): string {
// Inline code — must come before bold/italic
s = s.replace(/`([^`]+)`/g, '$1');
@@ -19,10 +28,13 @@ function inlineMarkdown(s: string): string {
s = s.replace(/\*\*(.+?)\*\*/g, '$1');
// Italic
s = s.replace(/\*(.+?)\*/g, '$1');
- // Links
+ // Links — only allow safe protocols (http, https, mailto)
s = s.replace(
/\[([^\]]+)\]\(([^)]+)\)/g,
- '$1',
+ (_, text, url) =>
+ isSafeUrl(url)
+ ? `${text}`
+ : text,
);
return s;
}
diff --git a/src/clawsharp.Plugin.Confluence/ConfluenceApiClient.cs b/src/clawsharp.Plugin.Confluence/ConfluenceApiClient.cs
index e81d1092..427d3ab0 100644
--- a/src/clawsharp.Plugin.Confluence/ConfluenceApiClient.cs
+++ b/src/clawsharp.Plugin.Confluence/ConfluenceApiClient.cs
@@ -7,7 +7,7 @@ namespace Clawsharp.Plugin.Confluence;
///
/// HTTP client for the Confluence REST API v2 with cursor-based pagination per D-10.
/// Uses a named injected via DI with SsrfGuard-protected
-/// per D-26.
+/// per D-26.
///
internal sealed class ConfluenceApiClient
{
diff --git a/src/clawsharp.Plugin.Gcs/GcsPlugin.cs b/src/clawsharp.Plugin.Gcs/GcsPlugin.cs
index 74351ee0..5168c8da 100644
--- a/src/clawsharp.Plugin.Gcs/GcsPlugin.cs
+++ b/src/clawsharp.Plugin.Gcs/GcsPlugin.cs
@@ -68,6 +68,6 @@ private static bool IsPrivateIpLikeName(string name)
if (!System.Net.IPAddress.TryParse(name, out var ip))
return false;
- return Clawsharp.Security.SsrfGuard.IsPrivateOrReservedAddress(ip);
+ return Security.SsrfGuard.IsPrivateOrReservedAddress(ip);
}
}
diff --git a/src/clawsharp/A2a/A2aAgentCardBuilder.cs b/src/clawsharp/A2a/A2aAgentCardBuilder.cs
index c2c5bd29..b9fd4570 100644
--- a/src/clawsharp/A2a/A2aAgentCardBuilder.cs
+++ b/src/clawsharp/A2a/A2aAgentCardBuilder.cs
@@ -8,8 +8,8 @@ namespace Clawsharp.A2a;
///
/// Builds the Agent Card for A2A discovery (/.well-known/agent-card.json).
/// Skills are derived 1:1 from the tool registry, filtered to Low/Medium sensitivity (D-10).
-/// Capabilities reflect runtime config (D-12). Metadata follows config override chains (D-13).
-/// Card is built once at startup and cached — tool registry is immutable at runtime (D-11).
+/// Capabilities reflect runtime config (D-12). Name falls back to "ClawSharp Agent" when
+/// a2a.agentCard.name is null. Card is built once at startup and cached (D-11).
///
public sealed class A2aAgentCardBuilder(
IToolRegistry toolRegistry,
diff --git a/src/clawsharp/A2a/A2aAttributes.cs b/src/clawsharp/A2a/A2aAttributes.cs
index e4945abd..86578de5 100644
--- a/src/clawsharp/A2a/A2aAttributes.cs
+++ b/src/clawsharp/A2a/A2aAttributes.cs
@@ -43,4 +43,18 @@ internal static class A2aAttributes
/// Unique chain identifier correlating delegation hops across instances.
internal const string DelegationChainId = "a2a.delegation.chain_id";
+
+ // ── Cooperative delegation metadata keys (propagated in A2A task metadata) ──
+
+ /// Metadata key: current delegation depth (incremented per hop).
+ internal const string MetaDepth = "clawsharp.delegation.depth";
+
+ /// Metadata key: maximum allowed delegation depth.
+ internal const string MetaMaxDepth = "clawsharp.delegation.maxDepth";
+
+ /// Metadata key: machine name of the originating instance.
+ internal const string MetaOriginInstance = "clawsharp.delegation.originInstance";
+
+ /// Metadata key: unique chain identifier for correlating delegation hops.
+ internal const string MetaChainId = "clawsharp.delegation.chainId";
}
diff --git a/src/clawsharp/A2a/A2aClientConfig.cs b/src/clawsharp/A2a/A2aClientConfig.cs
index 9ce22fea..37215a65 100644
--- a/src/clawsharp/A2a/A2aClientConfig.cs
+++ b/src/clawsharp/A2a/A2aClientConfig.cs
@@ -4,7 +4,7 @@ namespace Clawsharp.A2a;
/// Client-side A2A delegation config. Added to as nullable Client property.
/// Null = no delegation capability (zero tools registered).
///
-public sealed record A2aClientConfig
+public sealed class A2aClientConfig
{
/// Max delegation chain depth. Default: 3.
/// Uses set (not init) so STJ source-gen preserves defaults on deserialization.
@@ -19,7 +19,7 @@ public sealed record A2aClientConfig
}
/// Configuration for a single trusted external A2A agent.
-public sealed record TrustedAgentConfig
+public sealed class TrustedAgentConfig
{
/// Base URL of the external agent's A2A endpoint.
public required string Url { get; init; }
@@ -32,7 +32,7 @@ public sealed record TrustedAgentConfig
}
/// Authentication credentials for a trusted agent. Supports bearer token and API key.
-public sealed record AgentAuthConfig
+public sealed class AgentAuthConfig
{
/// Auth type: "bearer" or "apiKey".
public required string Type { get; init; }
diff --git a/src/clawsharp/A2a/A2aClientService.cs b/src/clawsharp/A2a/A2aClientService.cs
index 81fc8ac3..3975655e 100644
--- a/src/clawsharp/A2a/A2aClientService.cs
+++ b/src/clawsharp/A2a/A2aClientService.cs
@@ -1,3 +1,4 @@
+using System.Collections.Concurrent;
using System.Collections.Frozen;
using System.Net.Http.Headers;
using System.Text;
@@ -62,26 +63,27 @@ public async Task InitializeAsync(CancellationToken ct = default)
return;
}
- var clients = new Dictionary(AgentRegistry.Count, StringComparer.Ordinal);
- var cards = new Dictionary(AgentRegistry.Count, StringComparer.Ordinal);
+ var clients = new ConcurrentDictionary(StringComparer.Ordinal);
+ var cards = new ConcurrentDictionary(StringComparer.Ordinal);
- foreach (var (name, agentConfig) in AgentRegistry)
+ await Parallel.ForEachAsync(AgentRegistry, ct, async (kvp, token) =>
{
+ var (name, agentConfig) = kvp;
try
{
var uri = new Uri(agentConfig.Url);
// D-03: Validate URL via SsrfGuard at startup
- var ssrfResult = await SsrfGuard.CheckAsync(uri, ct).ConfigureAwait(false);
+ var ssrfResult = await SsrfGuard.CheckAsync(uri, token).ConfigureAwait(false);
if (ssrfResult is not null)
{
LogAgentUrlBlocked(_logger, name, agentConfig.Url, ssrfResult);
- continue;
+ return;
}
// Create HttpClient with auth headers pre-configured
var httpClient = _httpFactory.CreateClient("a2a-client");
- ConfigureAuth(httpClient, agentConfig.Auth);
+ ConfigureAuth(httpClient, agentConfig.Auth, name, _logger);
// Create A2AClient per agent (D-16)
var client = new A2AClient(uri, httpClient);
@@ -92,7 +94,7 @@ public async Task InitializeAsync(CancellationToken ct = default)
try
{
var resolver = new A2ACardResolver(uri, httpClient, "/.well-known/agent-card.json", null!);
- card = await resolver.GetAgentCardAsync(ct).ConfigureAwait(false);
+ card = await resolver.GetAgentCardAsync(token).ConfigureAwait(false);
LogAgentCardFetched(_logger, name, card.Name ?? name);
}
catch (Exception ex)
@@ -106,19 +108,20 @@ public async Task InitializeAsync(CancellationToken ct = default)
{
LogAgentInitFailed(_logger, name, ex);
}
- }
+ }).ConfigureAwait(false);
_clients = clients.ToFrozenDictionary(StringComparer.Ordinal);
_agentCards = cards.ToFrozenDictionary(StringComparer.Ordinal);
}
///
- /// Delegates a task to an external A2A agent. Returns the text result as a string.
- /// Never throws — errors are returned as descriptive strings (D-19).
+ /// Delegates a task to an external A2A agent. Returns (Text, IsError) so callers
+ /// can reliably classify outcomes. Never throws — errors are returned as descriptive
+ /// tuples with IsError = true (D-19).
/// Uses streaming by default (D-16), falls back to sync+poll when agent card
/// capabilities.streaming is false (D-17).
///
- public async Task DelegateAsync(
+ public async Task<(string Text, bool IsError)> DelegateAsync(
string agentName,
string taskText,
int? timeoutSeconds = null,
@@ -130,7 +133,7 @@ public async Task DelegateAsync(
var available = _clients.Count > 0
? string.Join(", ", _clients.Keys)
: "(none)";
- return $"Unknown agent '{agentName}'. Available: {available}";
+ return ($"Unknown agent '{agentName}'. Available: {available}", true);
}
try
@@ -154,21 +157,25 @@ public async Task DelegateAsync(
var supportsStreaming = _agentCards.TryGetValue(agentName, out var card)
&& card?.Capabilities?.Streaming == true;
- return supportsStreaming
+ var text = supportsStreaming
? await DelegateStreamingAsync(client, agentName, request, timeoutCts.Token).ConfigureAwait(false)
: await DelegateSyncAsync(client, agentName, request, timeoutCts.Token).ConfigureAwait(false);
+
+ return (text, false);
}
catch (OperationCanceledException)
{
- return $"Delegation to '{agentName}' failed: operation timed out or was cancelled.";
+ return ($"Delegation to '{agentName}' failed: operation timed out or was cancelled.", true);
}
catch (HttpRequestException ex)
{
- return $"Delegation to '{agentName}' failed: {ex.Message}";
+ _logger.LogWarning(ex, "A2A delegation to '{AgentName}' failed", agentName);
+ return ($"Delegation to '{agentName}' failed: the remote agent is unavailable.", true);
}
catch (Exception ex)
{
- return $"Delegation to '{agentName}' failed: {ex.Message}";
+ _logger.LogWarning(ex, "A2A delegation to '{AgentName}' failed unexpectedly", agentName);
+ return ($"Delegation to '{agentName}' failed: an unexpected error occurred.", true);
}
}
@@ -303,8 +310,9 @@ public static string ExtractTextFromTask(AgentTask task)
///
/// Configures authentication headers on an HttpClient based on .
+ /// Logs a warning for unrecognized auth types.
///
- private static void ConfigureAuth(HttpClient httpClient, AgentAuthConfig auth)
+ private static void ConfigureAuth(HttpClient httpClient, AgentAuthConfig auth, string agentName, ILogger logger)
{
switch (auth.Type.ToUpperInvariant())
{
@@ -321,6 +329,9 @@ private static void ConfigureAuth(HttpClient httpClient, AgentAuthConfig auth)
httpClient.DefaultRequestHeaders.Add("X-API-Key", auth.Key);
}
break;
+ default:
+ LogUnrecognizedAuthType(logger, auth.Type, agentName);
+ break;
}
}
@@ -340,4 +351,7 @@ private static void ConfigureAuth(HttpClient httpClient, AgentAuthConfig auth)
[LoggerMessage(Level = LogLevel.Debug, Message = "A2A delegation to '{AgentName}' reached state: {State}")]
private static partial void LogDelegationStateUpdate(ILogger logger, string agentName, string state);
+
+ [LoggerMessage(Level = LogLevel.Warning, Message = "Unrecognized auth type '{AuthType}' for agent '{AgentName}'")]
+ private static partial void LogUnrecognizedAuthType(ILogger logger, string authType, string agentName);
}
diff --git a/src/clawsharp/A2a/A2aConfig.cs b/src/clawsharp/A2a/A2aConfig.cs
index 8bb4d178..d5d07a2f 100644
--- a/src/clawsharp/A2a/A2aConfig.cs
+++ b/src/clawsharp/A2a/A2aConfig.cs
@@ -4,7 +4,7 @@ namespace Clawsharp.A2a;
/// A2A Protocol configuration. Null on AppConfig = disabled (zero overhead).
/// Minimum config: { "a2a": { "enabled": true } }
///
-public sealed record A2aConfig
+public sealed class A2aConfig
{
/// Whether A2A protocol endpoints are active.
public bool Enabled { get; init; }
@@ -20,7 +20,7 @@ public sealed record A2aConfig
}
/// Server-side A2A task processing configuration.
-public sealed record A2aServerConfig
+public sealed class A2aServerConfig
{
/// Minutes before completed/failed tasks are evicted. Default: 60.
/// Uses set (not init) so STJ source-gen preserves defaults on deserialization.
@@ -36,7 +36,7 @@ public sealed record A2aServerConfig
}
/// Agent Card metadata overrides for discovery.
-public sealed record AgentCardConfig
+public sealed class AgentCardConfig
{
/// Override agent name. Null = BotName from agent config, then "ClawSharp Agent".
public string? Name { get; init; }
@@ -49,7 +49,7 @@ public sealed record AgentCardConfig
}
/// Agent Card provider metadata overrides.
-public sealed record AgentProviderConfig
+public sealed class AgentProviderConfig
{
/// Organization name. Null = Organization.Name from config, then "ClawSharp".
public string? Organization { get; init; }
diff --git a/src/clawsharp/A2a/A2aDelegateTool.cs b/src/clawsharp/A2a/A2aDelegateTool.cs
index ac60755f..cd8035e9 100644
--- a/src/clawsharp/A2a/A2aDelegateTool.cs
+++ b/src/clawsharp/A2a/A2aDelegateTool.cs
@@ -83,25 +83,24 @@ public override async Task ExecuteAsync(JsonElement arguments, Cancellat
activity?.SetTag(A2aAttributes.Direction, "outbound");
activity?.SetTag(A2aAttributes.TargetAgent, agentName);
activity?.SetTag(A2aAttributes.DelegationDepth, currentDepth);
- if (metadata.TryGetValue("clawsharp.delegation.chainId", out var chainElement))
+ if (metadata.TryGetValue(A2aAttributes.MetaChainId, out var chainElement))
activity?.SetTag(A2aAttributes.DelegationChainId, chainElement.GetString());
string result;
var outcome = "failed";
try
{
- result = await _clientService.DelegateAsync(agentName, taskText, timeout, metadata, ct)
+ // DelegateAsync never throws — errors are returned via IsError (D-19).
+ var (text, isError) = await _clientService.DelegateAsync(agentName, taskText, timeout, metadata, ct)
.ConfigureAwait(false);
- outcome = result.StartsWith("Error", StringComparison.Ordinal) ? "failed" : "completed";
- }
- catch
- {
- outcome = "failed";
- throw;
+ outcome = isError ? "failed" : "completed";
+ result = text;
}
finally
{
activity?.SetTag(A2aAttributes.Outcome, outcome);
+ if (outcome == "failed")
+ activity?.SetStatus(ActivityStatusCode.Error, "A2A delegation failed");
var elapsed = Stopwatch.GetElapsedTime(startTimestamp);
_metrics.RecordTaskDuration(elapsed.TotalSeconds, "outbound");
if (outcome == "completed")
@@ -138,10 +137,10 @@ internal static Dictionary BuildDelegationMetadata(int curr
{
return new Dictionary
{
- ["clawsharp.delegation.depth"] = JsonSerializer.SerializeToElement(currentDepth + 1),
- ["clawsharp.delegation.maxDepth"] = JsonSerializer.SerializeToElement(depthLimit),
- ["clawsharp.delegation.originInstance"] = JsonSerializer.SerializeToElement(Environment.MachineName),
- ["clawsharp.delegation.chainId"] = JsonSerializer.SerializeToElement(
+ [A2aAttributes.MetaDepth] = JsonSerializer.SerializeToElement(currentDepth + 1),
+ [A2aAttributes.MetaMaxDepth] = JsonSerializer.SerializeToElement(depthLimit),
+ [A2aAttributes.MetaOriginInstance] = JsonSerializer.SerializeToElement(Environment.MachineName),
+ [A2aAttributes.MetaChainId] = JsonSerializer.SerializeToElement(
Guid.CreateVersion7().ToString("N")),
};
}
diff --git a/src/clawsharp/A2a/A2aRouteRegistrar.cs b/src/clawsharp/A2a/A2aRouteRegistrar.cs
index d6c7819e..f391c347 100644
--- a/src/clawsharp/A2a/A2aRouteRegistrar.cs
+++ b/src/clawsharp/A2a/A2aRouteRegistrar.cs
@@ -43,7 +43,8 @@ public void ConfigureServices(WebApplicationBuilder builder)
sp.GetRequiredService>(),
sp.GetRequiredService(),
sp.GetRequiredService(),
- sp.GetRequiredService()));
+ sp.GetRequiredService(),
+ sp.GetService()));
// SDK registration -- ITaskStore + IA2ARequestHandler already registered, TryAddSingleton is a no-op
builder.Services.AddA2AAgent(_agentCard);
diff --git a/src/clawsharp/A2a/A2aServerWithPush.cs b/src/clawsharp/A2a/A2aServerWithPush.cs
index 4d38f666..39f634f8 100644
--- a/src/clawsharp/A2a/A2aServerWithPush.cs
+++ b/src/clawsharp/A2a/A2aServerWithPush.cs
@@ -2,8 +2,11 @@
using System.Text.Json;
using A2A;
using Clawsharp.Config.Features;
+using Clawsharp.Core.Security;
+using Clawsharp.McpServer;
using Clawsharp.Security;
using Clawsharp.Webhooks;
+using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace Clawsharp.A2a;
@@ -28,8 +31,10 @@ public sealed partial class A2aServerWithPush : A2AServer
///
private readonly ConcurrentDictionary> _pushConfigs = new(StringComparer.Ordinal);
+ private readonly A2aTaskStore _taskStore;
private readonly WebhookQueueRegistry _queueRegistry;
private readonly DeliveryStorage _deliveryStorage;
+ private readonly IHttpContextAccessor? _httpContextAccessor;
private readonly ILogger _logger;
public A2aServerWithPush(
@@ -39,11 +44,14 @@ public A2aServerWithPush(
ILogger logger,
A2AServerOptions options,
WebhookQueueRegistry queueRegistry,
- DeliveryStorage deliveryStorage)
+ DeliveryStorage deliveryStorage,
+ IHttpContextAccessor? httpContextAccessor = null)
: base(handler, taskStore, notifier, logger, options)
{
+ _taskStore = taskStore;
_queueRegistry = queueRegistry;
_deliveryStorage = deliveryStorage;
+ _httpContextAccessor = httpContextAccessor;
_logger = logger;
// Wire up the push delivery trigger via task store callback
@@ -55,10 +63,13 @@ public A2aServerWithPush(
///
/// Creates a push notification config for a task. Validates the callback URL
/// against before storing (PUSH-04).
+ /// M-02: Verifies task ownership before allowing push config creation.
///
public override async Task CreateTaskPushNotificationConfigAsync(
CreateTaskPushNotificationConfigRequest request, CancellationToken cancellationToken)
{
+ VerifyTaskOwnership(request.TaskId);
+
var url = request.Config?.Url;
if (string.IsNullOrEmpty(url))
throw new A2AException("Push notification config must include a URL.", A2AErrorCode.InvalidParams);
@@ -84,31 +95,28 @@ public override async Task CreateTaskPushNotificatio
PushNotificationConfig = request.Config,
};
- _pushConfigs.AddOrUpdate(
- request.TaskId,
- _ => [config],
- (_, existing) =>
- {
- lock (existing)
- {
- existing.Add(config);
- }
- return existing;
- });
+ var list = _pushConfigs.GetOrAdd(request.TaskId, _ => []);
+ lock (list)
+ {
+ list.Add(config);
+ }
// Ensure a dynamic queue exists for this task's push notifications
_queueRegistry.TryCreateQueue($"a2a-push:{request.TaskId}");
- LogPushConfigCreated(_logger, request.TaskId, configId, url);
+ LogPushConfigCreated(_logger, request.TaskId, configId, RedactUrl(url));
return config;
}
///
/// Retrieves a specific push notification config by task ID and config ID.
+ /// M-02: Verifies task ownership before returning push config.
///
public override Task GetTaskPushNotificationConfigAsync(
GetTaskPushNotificationConfigRequest request, CancellationToken cancellationToken)
{
+ VerifyTaskOwnership(request.TaskId);
+
if (!_pushConfigs.TryGetValue(request.TaskId, out var configs))
throw new A2AException($"No push configs found for task '{request.TaskId}'.", A2AErrorCode.TaskNotFound);
@@ -128,10 +136,13 @@ public override Task GetTaskPushNotificationConfigAs
///
/// Lists all push notification configs for a task.
+ /// M-02: Verifies task ownership before listing push configs.
///
public override Task ListTaskPushNotificationConfigAsync(
ListTaskPushNotificationConfigRequest request, CancellationToken cancellationToken)
{
+ VerifyTaskOwnership(request.TaskId);
+
List snapshot;
if (_pushConfigs.TryGetValue(request.TaskId, out var configs))
@@ -154,26 +165,22 @@ public override Task ListTaskPushNotific
}
///
- /// Deletes a push notification config. If no configs remain for the task,
- /// removes the dynamic queue to free resources.
+ /// Deletes a push notification config. Empty lists are left in the dictionary
+ /// rather than eagerly removed — CleanupTask handles full eviction when the task
+ /// is evicted, avoiding a TOCTOU race with concurrent Create calls.
+ /// M-02: Verifies task ownership before allowing deletion.
///
public override Task DeleteTaskPushNotificationConfigAsync(
DeleteTaskPushNotificationConfigRequest request, CancellationToken cancellationToken)
{
+ VerifyTaskOwnership(request.TaskId);
+
if (!_pushConfigs.TryGetValue(request.TaskId, out var configs))
throw new A2AException($"No push configs found for task '{request.TaskId}'.", A2AErrorCode.TaskNotFound);
- bool removedLast;
lock (configs)
{
configs.RemoveAll(c => string.Equals(c.Id, request.Id, StringComparison.Ordinal));
- removedLast = configs.Count == 0;
- }
-
- if (removedLast)
- {
- _pushConfigs.TryRemove(request.TaskId, out _);
- _queueRegistry.RemoveQueue($"a2a-push:{request.TaskId}");
}
LogPushConfigDeleted(_logger, request.TaskId, request.Id);
@@ -214,6 +221,18 @@ internal async Task OnTaskStateChangedAsync(string taskId, AgentTask task, Cance
if (string.IsNullOrEmpty(pushUrl))
continue;
+ // Re-validate SSRF at delivery time to close the TOCTOU window between
+ // registration and delivery (MED-04: DNS could change between these events).
+ if (Uri.TryCreate(pushUrl, UriKind.Absolute, out var pushUri))
+ {
+ var ssrfError = await SsrfGuard.CheckAsync(pushUri, cancellationToken).ConfigureAwait(false);
+ if (ssrfError is not null)
+ {
+ LogPushUrlRejected(_logger, taskId, pushUrl, ssrfError);
+ continue;
+ }
+ }
+
var record = new WebhookDeliveryRecord
{
Id = WebhookSigner.NewEventId(),
@@ -247,6 +266,38 @@ internal async Task OnTaskStateChangedAsync(string taskId, AgentTask task, Cance
}
}
+ // ── Ownership verification (M-02) ──────────────────────────────────────────
+
+ ///
+ /// Extracts the caller identity from the current HTTP context and verifies
+ /// that the specified task belongs to the caller. Throws
+ /// with if ownership check fails.
+ /// Uses TaskNotFound (not Unauthorized) to avoid leaking task existence to non-owners.
+ ///
+ private void VerifyTaskOwnership(string taskId)
+ {
+ var callerId = GetCallerOwnerId();
+ if (!_taskStore.IsTaskOwnedBy(taskId, callerId))
+ {
+ LogPushOwnershipDenied(_logger, taskId, callerId ?? "(unknown)");
+ throw new A2AException(
+ $"Task '{taskId}' not found.",
+ A2AErrorCode.TaskNotFound);
+ }
+ }
+
+ ///
+ /// Extracts the authenticated caller's owner ID from the current HTTP context.
+ /// Returns the KeyId or User.Name from , or null
+ /// when no HTTP context is available.
+ ///
+ private string? GetCallerOwnerId()
+ {
+ var authResult = _httpContextAccessor?.HttpContext?.Items[BearerTokenAuthFilter.AuthResultKey]
+ as McpServerAuthResult;
+ return authResult?.KeyId ?? authResult?.User?.Name;
+ }
+
// ── Cleanup ───────────────────────────────────────────────────────────────
///
@@ -260,6 +311,17 @@ public void CleanupTask(string taskId)
_queueRegistry.RemoveQueue($"a2a-push:{taskId}");
}
+ ///
+ /// Strips query string and fragment from a URL to avoid logging auth tokens
+ /// that may be embedded in push notification callback URLs.
+ ///
+ private static string RedactUrl(string url)
+ {
+ if (Uri.TryCreate(url, UriKind.Absolute, out var uri))
+ return uri.GetLeftPart(UriPartial.Path);
+ return "(invalid url)";
+ }
+
// ── Source-generated log methods ──────────────────────────────────────────
[LoggerMessage(EventId = 1, Level = LogLevel.Information,
@@ -277,4 +339,8 @@ public void CleanupTask(string taskId)
[LoggerMessage(EventId = 4, Level = LogLevel.Warning,
Message = "Push URL rejected for task '{TaskId}': url={Url}, reason={Reason}")]
private static partial void LogPushUrlRejected(ILogger logger, string taskId, string url, string reason);
+
+ [LoggerMessage(EventId = 5, Level = LogLevel.Warning,
+ Message = "Push config ownership denied for task '{TaskId}': caller={CallerId}")]
+ private static partial void LogPushOwnershipDenied(ILogger logger, string taskId, string callerId);
}
diff --git a/src/clawsharp/A2a/A2aTaskEvictionService.cs b/src/clawsharp/A2a/A2aTaskEvictionService.cs
index 7edc0f52..54cabf0e 100644
--- a/src/clawsharp/A2a/A2aTaskEvictionService.cs
+++ b/src/clawsharp/A2a/A2aTaskEvictionService.cs
@@ -14,6 +14,7 @@ namespace Clawsharp.A2a;
public sealed partial class A2aTaskEvictionService : BackgroundService
{
private readonly A2aTaskStore _store;
+ private readonly A2aServerWithPush? _pushServer;
private readonly TimeSpan _ttl;
private readonly int _maxHistory;
private readonly ILogger _logger;
@@ -21,9 +22,11 @@ public sealed partial class A2aTaskEvictionService : BackgroundService
public A2aTaskEvictionService(
A2aTaskStore store,
A2aServerConfig? serverConfig,
- ILogger logger)
+ ILogger logger,
+ A2aServerWithPush? pushServer = null)
{
_store = store;
+ _pushServer = pushServer;
_ttl = TimeSpan.FromMinutes(serverConfig?.TaskTtlMinutes ?? 60);
_maxHistory = serverConfig?.MaxTaskHistory ?? 1000;
_logger = logger;
@@ -69,6 +72,7 @@ internal async Task EvictAsync(CancellationToken ct = default)
if (now - taskTimestamp >= _ttl)
{
await _store.DeleteTaskAsync(taskId, ct).ConfigureAwait(false);
+ _pushServer?.CleanupTask(taskId);
evictedCount++;
}
}
@@ -90,6 +94,7 @@ internal async Task EvictAsync(CancellationToken ct = default)
foreach (var (taskId, _) in evictionCandidates)
{
await _store.DeleteTaskAsync(taskId, ct).ConfigureAwait(false);
+ _pushServer?.CleanupTask(taskId);
evictedCount++;
}
}
diff --git a/src/clawsharp/A2a/A2aTaskProcessor.cs b/src/clawsharp/A2a/A2aTaskProcessor.cs
index 32327ed6..5ae744fc 100644
--- a/src/clawsharp/A2a/A2aTaskProcessor.cs
+++ b/src/clawsharp/A2a/A2aTaskProcessor.cs
@@ -6,6 +6,7 @@
using Clawsharp.Core;
using Clawsharp.Core.Security;
using Clawsharp.Core.Sessions;
+using Clawsharp.Security;
using Clawsharp.Core.Utilities;
using Clawsharp.Cost;
using Clawsharp.McpServer;
@@ -149,7 +150,7 @@ await updater.StartWorkAsync(
// ── D-14: Cooperative delegation depth from upstream ClawSharp ──
var inboundDepth = 0;
- if (context.Metadata?.TryGetValue("clawsharp.delegation.depth", out var depthElement) == true
+ if (context.Metadata?.TryGetValue(A2aAttributes.MetaDepth, out var depthElement) == true
&& depthElement.ValueKind == JsonValueKind.Number)
{
inboundDepth = depthElement.GetInt32();
@@ -245,11 +246,28 @@ await updater.RequireInputAsync(
}
}
- // ── Final artifact for sync callers (D-01) ──────────────────
- if (!context.StreamingResponse)
+ // ── H-02: Scan accumulated text for credential leaks ───────
+ var scanResult = LeakDetector.Scan(fullText.ToString());
+ var safeText = scanResult.Redacted;
+ if (!scanResult.IsClean)
+ {
+ LogLeakDetected(logger, context.TaskId, scanResult.Patterns.Count);
+ }
+
+ // ── Final artifact ──────────────────────────────────────────
+ if (context.StreamingResponse)
+ {
+ // Close the artifact stream with lastChunk=true per SDK contract.
+ await updater.AddArtifactAsync(
+ [Part.FromText("")],
+ append: true,
+ lastChunk: true,
+ cancellationToken: linked.Token).ConfigureAwait(false);
+ }
+ else
{
await updater.AddArtifactAsync(
- [Part.FromText(fullText.ToString())],
+ [Part.FromText(safeText)],
cancellationToken: linked.Token).ConfigureAwait(false);
}
@@ -258,7 +276,7 @@ await updater.CompleteAsync(
new Message
{
Role = Role.Agent,
- Parts = [Part.FromText(fullText.ToString())],
+ Parts = [Part.FromText(safeText)],
},
linked.Token).ConfigureAwait(false);
@@ -267,7 +285,7 @@ await updater.CompleteAsync(
// reverting a completed task if cancellation fires during bookkeeping
if (!context.IsContinuation)
session.Messages.Add(new ChatMessage(MessageRole.User, userPrompt));
- session.Messages.Add(new ChatMessage(MessageRole.Assistant, fullText.ToString()));
+ session.Messages.Add(new ChatMessage(MessageRole.Assistant, safeText));
await sessionStore.SaveAsync(session, CancellationToken.None).ConfigureAwait(false);
// ── Record cost (D-11) ──────────────────────────────────────
@@ -313,6 +331,8 @@ await updater.FailAsync(
{
// ── OTel: finalize span + record metrics ────────────────────
activity?.SetTag(A2aAttributes.Outcome, outcome);
+ if (outcome is "failed" or "canceled")
+ activity?.SetStatus(ActivityStatusCode.Error, $"A2A task {outcome}");
var elapsed = Stopwatch.GetElapsedTime(startTimestamp);
metrics.RecordTaskDuration(elapsed.TotalSeconds, "inbound");
if (outcome == "completed")
@@ -440,4 +460,8 @@ private static string MapPipelineError(Exception ex)
[LoggerMessage(EventId = 7, Level = LogLevel.Information,
Message = "A2A task {TaskId} requires input")]
private static partial void LogTaskInputRequired(ILogger logger, string taskId);
+
+ [LoggerMessage(EventId = 8, Level = LogLevel.Warning,
+ Message = "A2A task {TaskId} output redacted: {PatternCount} leak pattern(s) detected")]
+ private static partial void LogLeakDetected(ILogger logger, string taskId, int patternCount);
}
diff --git a/src/clawsharp/A2a/A2aTaskRecord.cs b/src/clawsharp/A2a/A2aTaskRecord.cs
index 991af190..a7a0ee97 100644
--- a/src/clawsharp/A2a/A2aTaskRecord.cs
+++ b/src/clawsharp/A2a/A2aTaskRecord.cs
@@ -14,6 +14,13 @@ public sealed record A2aTaskRecord
public required DateTimeOffset UpdatedAt { get; init; }
public string? OrgUserId { get; init; }
+ ///
+ /// Identity of the authenticated client that created/owns this task.
+ /// Used for IDOR protection — GetTaskAsync/ListTasksAsync filter by this field.
+ /// Null for tasks created before ownership tracking was added (backward compat).
+ ///
+ public string? OwnerId { get; init; }
+
///
/// Opaque SDK-serialized AgentTask JSON. Deserialized via A2AJsonUtilities.DefaultOptions,
/// NOT via A2aJsonContext. The SDK owns its own serialization.
diff --git a/src/clawsharp/A2a/A2aTaskStore.cs b/src/clawsharp/A2a/A2aTaskStore.cs
index d566b045..070a783c 100644
--- a/src/clawsharp/A2a/A2aTaskStore.cs
+++ b/src/clawsharp/A2a/A2aTaskStore.cs
@@ -2,6 +2,9 @@
using System.Text.Json;
using A2A;
using Clawsharp.Config;
+using Clawsharp.Core.Security;
+using Clawsharp.McpServer;
+using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace Clawsharp.A2a;
@@ -13,12 +16,21 @@ namespace Clawsharp.A2a;
/// On construction, the file is loaded with last-write-wins deduplication.
/// Pattern mirrors DeliveryStorage from the webhook subsystem.
///
+///
+/// Owner-based access control (M-01): each task records an OwnerId at creation time.
+/// and extract the current caller's
+/// identity from and filter results to owned tasks only.
+/// Tasks created before ownership tracking (OwnerId is null) are visible to all callers
+/// for backward compatibility.
+///
public sealed partial class A2aTaskStore : ITaskStore
{
private readonly ConcurrentDictionary _tasks = new(StringComparer.Ordinal);
+ private readonly ConcurrentDictionary _taskOwners = new(StringComparer.Ordinal);
private readonly SemaphoreSlim _writeLock = new(1, 1);
private readonly string _filePath;
private readonly ILogger _logger;
+ private readonly IHttpContextAccessor? _httpContextAccessor;
///
/// Optional callback invoked after a task is saved. Used by
@@ -40,17 +52,18 @@ internal Func? OnTaskSaved
///
/// Production constructor. Stores tasks at ~/.clawsharp/a2a/tasks.jsonl.
///
- public A2aTaskStore(ILogger logger, A2aServerConfig? serverConfig = null)
- : this(ConfigLoader.ExpandHome("~/.clawsharp/a2a"), logger)
+ public A2aTaskStore(ILogger logger, IHttpContextAccessor? httpContextAccessor = null)
+ : this(ConfigLoader.ExpandHome("~/.clawsharp/a2a"), logger, httpContextAccessor)
{
}
///
/// Internal constructor for tests. Accepts a custom directory path.
///
- internal A2aTaskStore(string directory, ILogger logger)
+ internal A2aTaskStore(string directory, ILogger logger, IHttpContextAccessor? httpContextAccessor = null)
{
_logger = logger;
+ _httpContextAccessor = httpContextAccessor;
Directory.CreateDirectory(directory);
_filePath = Path.Combine(directory, "tasks.jsonl");
LoadFromDisk();
@@ -63,15 +76,43 @@ internal IReadOnlyCollection> GetAllTasks()
=> _tasks.ToArray();
///
+ ///
+ /// M-01 IDOR protection: extracts the caller's identity from
+ /// and returns null if the task exists but belongs to a different owner.
+ /// Tasks with null OwnerId (pre-ownership) are visible to all authenticated callers.
+ ///
public Task GetTaskAsync(string taskId, CancellationToken cancellationToken = default)
- => Task.FromResult(_tasks.TryGetValue(taskId, out var task) ? task : null);
+ {
+ if (!_tasks.TryGetValue(taskId, out var task))
+ return Task.FromResult(null);
+
+ var callerId = GetCallerOwnerId();
+ if (callerId is not null
+ && _taskOwners.TryGetValue(taskId, out var ownerId)
+ && ownerId is not null
+ && !string.Equals(ownerId, callerId, StringComparison.Ordinal))
+ {
+ return Task.FromResult(null);
+ }
+
+ return Task.FromResult(task);
+ }
///
public async Task SaveTaskAsync(string taskId, AgentTask task, CancellationToken cancellationToken = default)
{
- ValidateTransition(taskId, task);
+ if (!ValidateTransition(taskId, task))
+ throw new InvalidOperationException($"Invalid A2A task state transition for task '{taskId}'.");
+
_tasks[taskId] = task;
+ // Record owner from current HTTP context on first save (task creation).
+ // Subsequent saves (state transitions) preserve the original owner.
+ if (!_taskOwners.ContainsKey(taskId))
+ {
+ _taskOwners[taskId] = GetCallerOwnerId();
+ }
+
var rawJson = JsonSerializer.Serialize(task, A2AJsonUtilities.DefaultOptions);
var record = new A2aTaskRecord
{
@@ -80,6 +121,7 @@ public async Task SaveTaskAsync(string taskId, AgentTask task, CancellationToken
State = task.Status?.State.ToString() ?? "Unknown",
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
+ OwnerId = _taskOwners.TryGetValue(taskId, out var owner) ? owner : null,
RawTaskJson = rawJson,
};
@@ -87,7 +129,7 @@ public async Task SaveTaskAsync(string taskId, AgentTask task, CancellationToken
await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
- await File.AppendAllTextAsync(_filePath, line + "\n", cancellationToken).ConfigureAwait(false);
+ await File.AppendAllLinesAsync(_filePath, [line], cancellationToken).ConfigureAwait(false);
}
finally
{
@@ -101,36 +143,80 @@ public async Task SaveTaskAsync(string taskId, AgentTask task, CancellationToken
}
}
+ /// Tombstone state value written to JSONL when a task is evicted.
+ internal const string DeletedState = "Deleted";
+
///
- public Task DeleteTaskAsync(string taskId, CancellationToken cancellationToken = default)
+ public async Task DeleteTaskAsync(string taskId, CancellationToken cancellationToken = default)
{
_tasks.TryRemove(taskId, out _);
- return Task.CompletedTask;
+ _taskOwners.TryRemove(taskId, out _);
+
+ // Append a tombstone record so LoadFromDisk skips this task after restart.
+ var tombstone = new A2aTaskRecord
+ {
+ TaskId = taskId,
+ ContextId = "",
+ State = DeletedState,
+ CreatedAt = DateTimeOffset.UtcNow,
+ UpdatedAt = DateTimeOffset.UtcNow,
+ RawTaskJson = "{}",
+ };
+ var line = JsonSerializer.Serialize(tombstone, A2aJsonlContext.Default.A2aTaskRecord);
+ await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ await File.AppendAllLinesAsync(_filePath, [line], cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ _writeLock.Release();
+ }
}
///
+ ///
+ /// M-01 IDOR protection: filters results to tasks owned by the current caller.
+ /// Tasks with null OwnerId (pre-ownership) are visible to all callers for backward compat.
+ /// L-16: Page size is clamped to [1, 100] to prevent excessive memory use.
+ ///
public Task ListTasksAsync(ListTasksRequest request, CancellationToken cancellationToken = default)
{
- var filtered = _tasks.Values.AsEnumerable();
+ var callerId = GetCallerOwnerId();
+ var filtered = _tasks.AsEnumerable();
+
+ // M-01: Filter by owner — only return tasks belonging to the caller
+ // or tasks with no owner (backward compat for pre-ownership tasks)
+ if (callerId is not null)
+ {
+ filtered = filtered.Where(kvp =>
+ !_taskOwners.TryGetValue(kvp.Key, out var ownerId)
+ || ownerId is null
+ || string.Equals(ownerId, callerId, StringComparison.Ordinal));
+ }
+
+ var filteredValues = filtered.Select(kvp => kvp.Value);
if (request.ContextId is not null)
{
- filtered = filtered.Where(t =>
+ filteredValues = filteredValues.Where(t =>
string.Equals(t.ContextId, request.ContextId, StringComparison.Ordinal));
}
if (request.Status is not null)
{
- filtered = filtered.Where(t => t.Status?.State == request.Status);
+ filteredValues = filteredValues.Where(t => t.Status?.State == request.Status);
}
// Order by task ID descending (ULID gives chronological + lexicographic ordering)
- var ordered = filtered
+ var ordered = filteredValues
.OrderByDescending(t => t.Id, StringComparer.Ordinal)
.ToList();
var totalFiltered = ordered.Count;
- var pageSize = request.PageSize ?? 20;
+
+ // L-16: Clamp page size to prevent excessive memory use
+ var pageSize = Math.Clamp(request.PageSize ?? 20, 1, 100);
// Apply cursor: skip to after the cursor ID
if (!string.IsNullOrEmpty(request.PageToken))
@@ -178,6 +264,7 @@ internal async Task CompactAsync(CancellationToken cancellationToken = default)
State = task.Status?.State.ToString() ?? "Unknown",
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
+ OwnerId = _taskOwners.TryGetValue(taskId, out var owner) ? owner : null,
RawTaskJson = rawJson,
};
lines.Add(JsonSerializer.Serialize(record, A2aJsonlContext.Default.A2aTaskRecord));
@@ -211,11 +298,20 @@ private void LoadFromDisk()
if (record is null)
continue;
+ // Tombstone records mark evicted tasks — remove from memory (last-write-wins).
+ if (string.Equals(record.State, DeletedState, StringComparison.Ordinal))
+ {
+ _tasks.TryRemove(record.TaskId, out _);
+ _taskOwners.TryRemove(record.TaskId, out _);
+ continue;
+ }
+
var agentTask = JsonSerializer.Deserialize(
record.RawTaskJson, A2AJsonUtilities.DefaultOptions);
if (agentTask is not null)
{
_tasks[record.TaskId] = agentTask; // last-write-wins dedup
+ _taskOwners[record.TaskId] = record.OwnerId; // restore owner mapping
}
}
catch (JsonException ex)
@@ -227,20 +323,25 @@ private void LoadFromDisk()
LogLoadedTasks(_logger, _tasks.Count, _filePath);
}
- private void ValidateTransition(string taskId, AgentTask newTask)
+ ///
+ /// Validates A2A task state transitions. Returns true if the transition is valid
+ /// (or no prior state exists), false if the transition violates the state machine.
+ /// L-10: invalid transitions are now rejected, not just logged.
+ ///
+ private bool ValidateTransition(string taskId, AgentTask newTask)
{
if (!_tasks.TryGetValue(taskId, out var existing))
- return;
+ return true; // New task, no prior state
var oldState = existing.Status?.State;
var newState = newTask.Status?.State;
if (oldState is null || newState is null)
- return;
+ return true;
// Same state is always allowed (idempotent save)
if (oldState == newState)
- return;
+ return true;
var isValid = oldState switch
{
@@ -255,6 +356,36 @@ private void ValidateTransition(string taskId, AgentTask newTask)
{
LogInvalidTransition(_logger, oldState.Value.ToString(), newState.Value.ToString(), taskId);
}
+
+ return isValid;
+ }
+
+ ///
+ /// Extracts the authenticated caller's owner ID from the current HTTP context.
+ /// Returns the KeyId or User.Name from , or null
+ /// when no HTTP context is available (e.g., eviction service, tests).
+ ///
+ private string? GetCallerOwnerId()
+ {
+ var authResult = _httpContextAccessor?.HttpContext?.Items[BearerTokenAuthFilter.AuthResultKey]
+ as McpServerAuthResult;
+ return authResult?.KeyId ?? authResult?.User?.Name;
+ }
+
+ ///
+ /// Checks whether a task is owned by the specified owner. Used by
+ /// for push notification IDOR protection (M-02). Returns true if the task has no owner
+ /// (backward compat) or if the owner matches.
+ ///
+ internal bool IsTaskOwnedBy(string taskId, string? callerId)
+ {
+ if (callerId is null)
+ return true; // No caller identity available — allow (e.g., localhost bypass)
+
+ if (!_taskOwners.TryGetValue(taskId, out var ownerId) || ownerId is null)
+ return true; // Pre-ownership task — allow for backward compat
+
+ return string.Equals(ownerId, callerId, StringComparison.Ordinal);
}
// ── Source-generated log methods ─────────────────────────────────────────
diff --git a/src/clawsharp/Analytics/EfInteractionStore.cs b/src/clawsharp/Analytics/EfInteractionStore.cs
index e2774b9e..c4429936 100644
--- a/src/clawsharp/Analytics/EfInteractionStore.cs
+++ b/src/clawsharp/Analytics/EfInteractionStore.cs
@@ -22,12 +22,12 @@ public sealed partial class EfInteractionStore(
public async Task AppendAsync(InteractionRecord record, CancellationToken ct = default)
{
- await EnsureInitializedAsync(ct);
- await using var db = await contextFactory.CreateDbContextAsync(ct);
+ await EnsureInitializedAsync(ct).ConfigureAwait(false);
+ await using var db = await contextFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
// Get or create conversation thread for this session
var thread = await db.Set()
- .FirstOrDefaultAsync(t => t.SessionId == record.SessionId, ct);
+ .FirstOrDefaultAsync(t => t.SessionId == record.SessionId, ct).ConfigureAwait(false);
if (thread is null)
{
@@ -40,23 +40,23 @@ public async Task AppendAsync(InteractionRecord record, CancellationToken ct = d
try
{
- await db.SaveChangesAsync(ct);
+ await db.SaveChangesAsync(ct).ConfigureAwait(false);
}
catch (DbUpdateException)
{
// Concurrent insert won the race — reload the existing thread
db.ChangeTracker.Clear();
thread = await db.Set()
- .FirstAsync(t => t.SessionId == record.SessionId, ct);
+ .FirstAsync(t => t.SessionId == record.SessionId, ct).ConfigureAwait(false);
}
}
- await using var transaction = await db.Database.BeginTransactionAsync(ct);
+ await using var transaction = await db.Database.BeginTransactionAsync(ct).ConfigureAwait(false);
var entity = ToEntity(record);
entity.ConversationThreadId = thread.Id;
db.Set().Add(entity);
- await db.SaveChangesAsync(ct);
+ await db.SaveChangesAsync(ct).ConfigureAwait(false);
// Insert per-message rows
var now = record.Timestamp;
@@ -91,19 +91,19 @@ public async Task AppendAsync(InteractionRecord record, CancellationToken ct = d
Timestamp = now,
});
- await db.SaveChangesAsync(ct);
- await transaction.CommitAsync(ct);
+ await db.SaveChangesAsync(ct).ConfigureAwait(false);
+ await transaction.CommitAsync(ct).ConfigureAwait(false);
}
public async Task> ReadAllAsync(CancellationToken ct = default)
{
- await EnsureInitializedAsync(ct);
- await using var db = await contextFactory.CreateDbContextAsync(ct);
+ await EnsureInitializedAsync(ct).ConfigureAwait(false);
+ await using var db = await contextFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
var entities = await db.Set()
.AsNoTracking()
.OrderBy(e => e.Id)
- .ToListAsync(ct);
+ .ToListAsync(ct).ConfigureAwait(false);
return entities.Select(ToRecord).ToList();
}
@@ -115,7 +115,7 @@ private async Task EnsureInitializedAsync(CancellationToken ct)
return;
}
- await _initLock.WaitAsync(ct);
+ await _initLock.WaitAsync(ct).ConfigureAwait(false);
try
{
if (_initialized)
@@ -123,8 +123,8 @@ private async Task EnsureInitializedAsync(CancellationToken ct)
return;
}
- await using var db = await contextFactory.CreateDbContextAsync(ct);
- await db.Database.MigrateAsync(ct);
+ await using var db = await contextFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
+ await db.Database.MigrateAsync(ct).ConfigureAwait(false);
_initialized = true;
LogDatabaseInitialized(typeof(TContext).Name);
}
diff --git a/src/clawsharp/Analytics/InteractionStorage.cs b/src/clawsharp/Analytics/InteractionStorage.cs
index a335bba7..178776e2 100644
--- a/src/clawsharp/Analytics/InteractionStorage.cs
+++ b/src/clawsharp/Analytics/InteractionStorage.cs
@@ -1,5 +1,6 @@
using System.Text.Json;
using Clawsharp.Config;
+using Clawsharp.Core.Utilities;
namespace Clawsharp.Analytics;
@@ -25,7 +26,7 @@ public sealed class InteractionStorage : IInteractionStore
public InteractionStorage()
{
var dir = ConfigLoader.ExpandHome("~/.clawsharp");
- Directory.CreateDirectory(dir);
+ FilePermissions.EnsureRestrictedDirectory(dir);
_filePath = Path.Combine(dir, "interactions.jsonl");
}
@@ -48,7 +49,7 @@ public async Task AppendAsync(InteractionRecord record, CancellationToken ct = d
await _writeLock.WaitAsync(ct).ConfigureAwait(false);
try
{
- await File.AppendAllTextAsync(_filePath, json + "\n", ct).ConfigureAwait(false);
+ await File.AppendAllLinesAsync(_filePath, [json], ct).ConfigureAwait(false);
// Invalidate the cache — next ReadAllAsync will re-read the file
lock (_cacheLock)
diff --git a/src/clawsharp/Analytics/InteractionTracker.cs b/src/clawsharp/Analytics/InteractionTracker.cs
index 419928a7..2adf8a72 100644
--- a/src/clawsharp/Analytics/InteractionTracker.cs
+++ b/src/clawsharp/Analytics/InteractionTracker.cs
@@ -59,7 +59,7 @@ public async Task RecordAsync(
try
{
- await store.AppendAsync(record, ct);
+ await store.AppendAsync(record, ct).ConfigureAwait(false);
LogInteractionRecorded(sessionId, model, cost, savings);
}
catch (Exception ex)
@@ -70,7 +70,7 @@ public async Task RecordAsync(
if (storeInMemory)
{
- await StoreMemoryFactAsync(record, ct);
+ await StoreMemoryFactAsync(record, ct).ConfigureAwait(false);
}
}
@@ -112,7 +112,7 @@ private async Task StoreMemoryFactAsync(InteractionRecord record, CancellationTo
$"{record.InputTokens:N0} in / {record.OutputTokens:N0} out tokens, " +
$"${record.CostUsd:F4} cost, ${record.CacheSavingsUsd:F4} cache savings ({cacheRate:F0}% cache hit).{toolInfo}{thinkingInfo}";
- await memory.AppendFactAsync(fact, ct);
+ await memory.AppendFactAsync(fact, ct).ConfigureAwait(false);
}
catch (Exception ex)
{
diff --git a/src/clawsharp/Auth/AuthStore.cs b/src/clawsharp/Auth/AuthStore.cs
index 1900f226..9fcb7046 100644
--- a/src/clawsharp/Auth/AuthStore.cs
+++ b/src/clawsharp/Auth/AuthStore.cs
@@ -20,7 +20,7 @@ public static async Task SaveAsync(string provider, OAuthToken token, Cancellati
var path = GetTokenPath(provider);
var tmpPath = path + ".tmp";
var json = JsonSerializer.Serialize(token, AuthJsonContext.Default.OAuthToken);
- await File.WriteAllTextAsync(tmpPath, json, ct);
+ await File.WriteAllTextAsync(tmpPath, json, ct).ConfigureAwait(false);
// Restrict file permissions on Unix (owner read/write only)
if (!OperatingSystem.IsWindows())
@@ -39,7 +39,7 @@ public static async Task SaveAsync(string provider, OAuthToken token, Cancellati
return null;
}
- var json = await File.ReadAllTextAsync(path, ct);
+ var json = await File.ReadAllTextAsync(path, ct).ConfigureAwait(false);
return JsonSerializer.Deserialize(json, AuthJsonContext.Default.OAuthToken);
}
diff --git a/src/clawsharp/Auth/GitHubDeviceFlow.cs b/src/clawsharp/Auth/GitHubDeviceFlow.cs
index ce8962e6..bd47d2e0 100644
--- a/src/clawsharp/Auth/GitHubDeviceFlow.cs
+++ b/src/clawsharp/Auth/GitHubDeviceFlow.cs
@@ -26,7 +26,7 @@ public sealed class GitHubDeviceFlow(IHttpClientFactory httpFactory)
http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
// Step 1: Request device code
- var deviceCode = await RequestDeviceCodeAsync(http, ct);
+ var deviceCode = await RequestDeviceCodeAsync(http, ct).ConfigureAwait(false);
if (deviceCode is null)
{
AnsiConsole.MarkupLine("[red][[auth]][/] Failed to request device code from GitHub.");
@@ -40,7 +40,7 @@ public sealed class GitHubDeviceFlow(IHttpClientFactory httpFactory)
AnsiConsole.MarkupLine(" Waiting for authorization...");
// Step 2: Poll for GitHub access token
- var githubToken = await PollForAccessTokenAsync(http, deviceCode, ct);
+ var githubToken = await PollForAccessTokenAsync(http, deviceCode, ct).ConfigureAwait(false);
if (githubToken is null)
{
AnsiConsole.MarkupLine("[red][[auth]][/] Device flow authorization timed out or was denied.");
@@ -50,7 +50,7 @@ public sealed class GitHubDeviceFlow(IHttpClientFactory httpFactory)
AnsiConsole.MarkupLine(" GitHub authorization successful. Fetching Copilot token...");
// Step 3: Exchange GitHub token for Copilot token
- var copilotToken = await ExchangeForCopilotTokenAsync(http, githubToken, ct);
+ var copilotToken = await ExchangeForCopilotTokenAsync(http, githubToken, ct).ConfigureAwait(false);
if (copilotToken is null)
{
AnsiConsole.MarkupLine("[red][[auth]][/] Failed to obtain Copilot token. Ensure your GitHub account has Copilot access.");
@@ -68,7 +68,7 @@ public sealed class GitHubDeviceFlow(IHttpClientFactory httpFactory)
{
using var http = httpFactory.CreateClient("llm");
http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
- return await ExchangeForCopilotTokenAsync(http, githubToken, ct);
+ return await ExchangeForCopilotTokenAsync(http, githubToken, ct).ConfigureAwait(false);
}
private static async Task RequestDeviceCodeAsync(HttpClient http, CancellationToken ct)
@@ -81,15 +81,15 @@ public sealed class GitHubDeviceFlow(IHttpClientFactory httpFactory)
try
{
- var resp = await http.PostAsync("https://github.com/login/device/code", body, ct);
+ var resp = await http.PostAsync("https://github.com/login/device/code", body, ct).ConfigureAwait(false);
if (!resp.IsSuccessStatusCode)
{
- var err = await resp.Content.ReadAsStringAsync(ct);
+ var err = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
AnsiConsole.MarkupLine($"[red][[auth]][/] Device code request failed ({resp.StatusCode}): {Markup.Escape(err)}");
return null;
}
- var json = await resp.Content.ReadAsStringAsync(ct);
+ var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
return JsonSerializer.Deserialize(json, AuthJsonContext.Default.GitHubDeviceCodeResponse);
}
catch (Exception ex)
@@ -108,7 +108,7 @@ public sealed class GitHubDeviceFlow(IHttpClientFactory httpFactory)
while (DateTimeOffset.UtcNow < deadline)
{
ct.ThrowIfCancellationRequested();
- await Task.Delay(TimeSpan.FromSeconds(interval), ct);
+ await Task.Delay(TimeSpan.FromSeconds(interval), ct).ConfigureAwait(false);
var body = new FormUrlEncodedContent(new Dictionary
{
@@ -119,8 +119,8 @@ public sealed class GitHubDeviceFlow(IHttpClientFactory httpFactory)
try
{
- var resp = await http.PostAsync("https://github.com/login/oauth/access_token", body, ct);
- var json = await resp.Content.ReadAsStringAsync(ct);
+ var resp = await http.PostAsync("https://github.com/login/oauth/access_token", body, ct).ConfigureAwait(false);
+ var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
var tokenResp = JsonSerializer.Deserialize(json, AuthJsonContext.Default.GitHubAccessTokenResponse);
if (tokenResp is null)
@@ -177,15 +177,15 @@ public sealed class GitHubDeviceFlow(IHttpClientFactory httpFactory)
req.Headers.Authorization = new AuthenticationHeaderValue("token", githubToken);
req.Headers.UserAgent.Add(new ProductInfoHeaderValue("clawsharp", "1.0"));
- var resp = await http.SendAsync(req, ct);
+ var resp = await http.SendAsync(req, ct).ConfigureAwait(false);
if (!resp.IsSuccessStatusCode)
{
- var err = await resp.Content.ReadAsStringAsync(ct);
+ var err = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
AnsiConsole.MarkupLine($"[red][[auth]][/] Copilot token exchange failed ({resp.StatusCode}): {Markup.Escape(err)}");
return null;
}
- var json = await resp.Content.ReadAsStringAsync(ct);
+ var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
var copilotResp = JsonSerializer.Deserialize(json, AuthJsonContext.Default.CopilotTokenResponse);
if (copilotResp is null || string.IsNullOrEmpty(copilotResp.Token))
{
diff --git a/src/clawsharp/Channels/BridgePollingChannelBase.cs b/src/clawsharp/Channels/BridgePollingChannelBase.cs
index 566b543e..6a3e3249 100644
--- a/src/clawsharp/Channels/BridgePollingChannelBase.cs
+++ b/src/clawsharp/Channels/BridgePollingChannelBase.cs
@@ -1,5 +1,3 @@
-using System.Text;
-using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using Clawsharp.Config;
using Clawsharp.Core;
@@ -229,7 +227,7 @@ private async Task PollOnceAsync(CancellationToken ct)
// Static AllowFrom + dynamic approved senders
if (!_allowPolicy.IsAllowed(senderId) &&
- !await _approvedSenders.IsApprovedAsync(Name.Value, senderId).ConfigureAwait(false))
+ !await _approvedSenders.IsApprovedAsync(Name.Value, senderId, ct).ConfigureAwait(false))
{
LogBlockedSender(Logger, Name.Value, senderId);
continue;
@@ -258,8 +256,7 @@ public virtual async Task SendAsync(OutboundMessage message, CancellationToken c
}
var req = MapToSendRequest(message);
- var json = JsonSerializer.Serialize(req, SendRequestTypeInfo);
- using var content = new StringContent(json, Encoding.UTF8, "application/json");
+ using var content = Utf8JsonContent.Create(req, SendRequestTypeInfo);
try
{
diff --git a/src/clawsharp/Channels/Cli/CliChannel.cs b/src/clawsharp/Channels/Cli/CliChannel.cs
index 192ba2a7..41bb8a43 100644
--- a/src/clawsharp/Channels/Cli/CliChannel.cs
+++ b/src/clawsharp/Channels/Cli/CliChannel.cs
@@ -31,7 +31,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
AnsiConsole.MarkupLine("[cyan]clawsharp[/] — type your message, Ctrl+C to exit\n");
AnsiConsole.Markup("[green]> [/]");
- await RunMessageLoopAsync(stoppingToken);
+ await RunMessageLoopAsync(stoppingToken).ConfigureAwait(false);
}
private async Task RunMessageLoopAsync(CancellationToken stoppingToken)
@@ -43,7 +43,7 @@ private async Task RunMessageLoopAsync(CancellationToken stoppingToken)
// TaskCompletionSource so that cancellation returns immediately
// even if Console.ReadLine() stays blocked (the background thread
// is IsBackground=true so it won't prevent process exit).
- var line = await ReadLineAsync(stoppingToken);
+ var line = await ReadLineAsync(stoppingToken).ConfigureAwait(false);
if (line is null || stoppingToken.IsCancellationRequested)
{
break;
@@ -62,7 +62,7 @@ await bus.PublishAsync(new InboundMessage(
SenderId: "cli-user",
SenderName: "User",
Text: line
- ), stoppingToken);
+ ), stoppingToken).ConfigureAwait(false);
// The next "> " prompt is printed by SendAsync/StreamAsync after the response.
}
catch (OperationCanceledException)
@@ -93,7 +93,7 @@ await bus.PublishAsync(new InboundMessage(
Name = "CLI-ReadLine"
};
thread.Start(tcs);
- return await tcs.Task;
+ return await tcs.Task.ConfigureAwait(false);
}
[LoggerMessage(EventId = 1, Level = LogLevel.Error, Message = "Error processing CLI input")]
@@ -111,7 +111,7 @@ public async Task StreamAsync(OutboundMessage message, IAsyncEnumerable
{
AnsiConsole.Markup("[blue]Assistant:[/] ");
var first = true;
- await foreach (var token in tokens.WithCancellation(ct))
+ await foreach (var token in tokens.WithCancellation(ct).ConfigureAwait(false))
{
if (first)
{
diff --git a/src/clawsharp/Channels/Discord/DiscordChannel.cs b/src/clawsharp/Channels/Discord/DiscordChannel.cs
index 334c562e..84542a73 100644
--- a/src/clawsharp/Channels/Discord/DiscordChannel.cs
+++ b/src/clawsharp/Channels/Discord/DiscordChannel.cs
@@ -43,6 +43,7 @@ public async Task SendAsync(OutboundMessage message, CancellationToken ct = defa
if (!result.IsSuccess)
{
LogSendError(logger, result.Error);
+ break;
}
}
}
diff --git a/src/clawsharp/Channels/Discord/DiscordMessageResponder.cs b/src/clawsharp/Channels/Discord/DiscordMessageResponder.cs
index 22df653d..9ed97a93 100644
--- a/src/clawsharp/Channels/Discord/DiscordMessageResponder.cs
+++ b/src/clawsharp/Channels/Discord/DiscordMessageResponder.cs
@@ -151,7 +151,7 @@ private async Task CheckUserAllowedAsync(
string authorId, bool isDm, Snowflake channelId, string username, CancellationToken ct)
{
var isAllowed = _allowPolicy.IsAllowed(authorId)
- || await approvedSenders.IsApprovedAsync("discord", authorId, ct);
+ || await approvedSenders.IsApprovedAsync(ChannelName.Discord.Value, authorId, ct);
if (isAllowed)
{
return true;
@@ -161,7 +161,7 @@ private async Task CheckUserAllowedAsync(
{
try
{
- var code = await pairingStore.GetOrCreateCodeAsync("discord", authorId, username, ct);
+ var code = await pairingStore.GetOrCreateCodeAsync(ChannelName.Discord.Value, authorId, username, ct);
var msg = $"Hi! To use this bot, send your operator the pairing code: **{code}**\n" +
"This code expires in 24 hours.";
await restChannel.CreateMessageAsync(channelId, msg, ct: ct);
diff --git a/src/clawsharp/Channels/Irc/IrcChannel.cs b/src/clawsharp/Channels/Irc/IrcChannel.cs
index aaddcfa1..3b9a4310 100644
--- a/src/clawsharp/Channels/Irc/IrcChannel.cs
+++ b/src/clawsharp/Channels/Irc/IrcChannel.cs
@@ -89,8 +89,11 @@ public async Task SendAsync(OutboundMessage message, CancellationToken ct = defa
}
var target = message.RecipientId;
- // Split long messages (IRC line limit ~512 bytes)
- var text = message.Text;
+ // Strip CR/LF to prevent protocol injection — an LLM response containing \r\n
+ // would otherwise be interpreted as multiple IRC commands by the server.
+ var text = message.Text
+ .Replace("\r", "", StringComparison.Ordinal)
+ .Replace("\n", " ", StringComparison.Ordinal);
while (text.Length > 0)
{
var chunk = text;
diff --git a/src/clawsharp/Channels/Lark/LarkChannel.cs b/src/clawsharp/Channels/Lark/LarkChannel.cs
index af5dcfae..23818407 100644
--- a/src/clawsharp/Channels/Lark/LarkChannel.cs
+++ b/src/clawsharp/Channels/Lark/LarkChannel.cs
@@ -149,20 +149,24 @@ protected override async Task HandleRequestAsync(HttpListenerContext ctx, Cancel
return;
}
- // MED-45: Signature verification — REQUIRE valid signature when token is configured.
- // If no token is configured, we already logged a warning at startup (LogNoVerificationToken).
- if (_verificationToken.Length > 0)
+ // Signature verification — REQUIRE valid signature when token is configured.
+ // When no token is configured, reject message events entirely to prevent forged webhooks.
+ if (_verificationToken.Length == 0)
{
- var timestamp = req.Headers["X-Lark-Request-Timestamp"] ?? "";
- var nonce = req.Headers["X-Lark-Request-Nonce"] ?? "";
- var signature = req.Headers["X-Lark-Signature"];
- if (signature is null || !VerifySignature(timestamp, nonce, bodyBytes, signature))
- {
- LogInvalidSignature();
- resp.StatusCode = 403;
- resp.Close();
- return;
- }
+ resp.StatusCode = 403;
+ resp.Close();
+ return;
+ }
+
+ var timestamp = req.Headers["X-Lark-Request-Timestamp"] ?? "";
+ var nonce = req.Headers["X-Lark-Request-Nonce"] ?? "";
+ var signature = req.Headers["X-Lark-Signature"];
+ if (signature is null || !VerifySignature(timestamp, nonce, bodyBytes, signature))
+ {
+ LogInvalidSignature();
+ resp.StatusCode = 403;
+ resp.Close();
+ return;
}
// Handle im.message.receive_v1
@@ -303,8 +307,7 @@ public async Task SendAsync(OutboundMessage message, CancellationToken ct = defa
MsgType = LarkMessageType.Text
};
- var json = JsonSerializer.Serialize(sendReq, LarkJsonContext.Default.LarkSendMessageRequest);
- using var content = new StringContent(json, Encoding.UTF8, "application/json");
+ using var content = Utf8JsonContent.Create(sendReq, LarkJsonContext.Default.LarkSendMessageRequest);
try
{
@@ -317,7 +320,7 @@ public async Task SendAsync(OutboundMessage message, CancellationToken ct = defa
if (!resp.IsSuccessStatusCode)
{
var responseBody = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
- LogSendError(responseBody);
+ LogSendError(TruncateResponseBody(responseBody));
}
}
catch (Exception ex)
@@ -346,14 +349,14 @@ public async Task SendAsync(OutboundMessage message, CancellationToken ct = defa
AppSecret = _appSecret
};
- var json = JsonSerializer.Serialize(tokenReq, LarkJsonContext.Default.LarkTokenRequest);
- using var content = new StringContent(json, Encoding.UTF8, "application/json");
+ using var content = Utf8JsonContent.Create(tokenReq, LarkJsonContext.Default.LarkTokenRequest);
using var resp = await _http.PostAsync(
"open-apis/auth/v3/tenant_access_token/internal/", content, ct).ConfigureAwait(false);
if (!resp.IsSuccessStatusCode)
{
- LogTokenHttpError(await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false));
+ var tokenBody = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
+ LogTokenHttpError(TruncateResponseBody(tokenBody));
return null;
}
@@ -378,6 +381,10 @@ public async Task SendAsync(OutboundMessage message, CancellationToken ct = defa
}
}
+ /// Truncates response bodies to avoid logging sensitive data (tokens, session info).
+ private static string TruncateResponseBody(string body, int maxLength = 500) =>
+ body.Length > maxLength ? string.Concat(body.AsSpan(0, maxLength), "...(truncated)") : body;
+
// ── LoggerMessage methods ────────────────────────────────────────
[LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "Starting Lark webhook listener on port {Port}")]
diff --git a/src/clawsharp/Channels/Line/LineChannel.cs b/src/clawsharp/Channels/Line/LineChannel.cs
index 3af4f994..14998dad 100644
--- a/src/clawsharp/Channels/Line/LineChannel.cs
+++ b/src/clawsharp/Channels/Line/LineChannel.cs
@@ -209,8 +209,7 @@ public async Task SendAsync(OutboundMessage message, CancellationToken ct = defa
Messages = [new LineTextMessage { Text = message.Text }]
};
- var json = JsonSerializer.Serialize(req, LineJsonContext.Default.LinePushRequest);
- using var content = new StringContent(json, Encoding.UTF8, "application/json");
+ using var content = Utf8JsonContent.Create(req, LineJsonContext.Default.LinePushRequest);
try
{
@@ -222,7 +221,7 @@ public async Task SendAsync(OutboundMessage message, CancellationToken ct = defa
if (!resp.IsSuccessStatusCode)
{
var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
- LogSendError(_logger, body);
+ LogSendError(_logger, TruncateResponseBody(body));
}
}
catch (Exception ex)
@@ -231,6 +230,10 @@ public async Task SendAsync(OutboundMessage message, CancellationToken ct = defa
}
}
+ /// Truncates response bodies to avoid logging sensitive data (tokens, session info).
+ private static string TruncateResponseBody(string body, int maxLength = 500) =>
+ body.Length > maxLength ? string.Concat(body.AsSpan(0, maxLength), "...(truncated)") : body;
+
[LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "Starting LINE webhook listener on port {Port}")]
private static partial void LogStartingWebhook(ILogger logger, int port);
diff --git a/src/clawsharp/Channels/Matrix/MatrixChannel.cs b/src/clawsharp/Channels/Matrix/MatrixChannel.cs
index eb6c3729..7d646bb2 100644
--- a/src/clawsharp/Channels/Matrix/MatrixChannel.cs
+++ b/src/clawsharp/Channels/Matrix/MatrixChannel.cs
@@ -1,5 +1,4 @@
using System.Net.Http.Headers;
-using System.Text;
using System.Text.Json;
using Clawsharp.Config;
using Clawsharp.Core;
@@ -150,10 +149,10 @@ public async Task SendAsync(OutboundMessage message, CancellationToken ct = defa
HttpMethod? method = null)
{
var httpMethod = method ?? HttpMethod.Post;
- var json = JsonSerializer.Serialize(request, request.RequestTypeInfo);
+ var jsonBytes = JsonSerializer.SerializeToUtf8Bytes(request, request.RequestTypeInfo);
// First attempt.
- using (var content = new StringContent(json, Encoding.UTF8, "application/json"))
+ using (var content = Utf8JsonContent.FromUtf8Bytes(jsonBytes))
using (var req = CreateRequest(httpMethod, request.Url, content))
{
using var resp = await _http.SendAsync(req, ct);
@@ -171,20 +170,22 @@ public async Task SendAsync(OutboundMessage message, CancellationToken ct = defa
}
else
{
- LogSendFailed(_logger, await resp.Content.ReadAsStringAsync(ct));
+ var body = await resp.Content.ReadAsStringAsync(ct);
+ LogSendFailed(_logger, TruncateResponseBody(body));
return default;
}
}
// Retry after successful re-login.
- using (var retryContent = new StringContent(json, Encoding.UTF8, "application/json"))
+ using (var retryContent = Utf8JsonContent.FromUtf8Bytes(jsonBytes))
using (var retryReq = CreateRequest(httpMethod, request.Url, retryContent))
{
using var retryResp = await _http.SendAsync(retryReq, ct);
if (!retryResp.IsSuccessStatusCode)
{
- LogSendFailed(_logger, await retryResp.Content.ReadAsStringAsync(ct));
+ var retryBody = await retryResp.Content.ReadAsStringAsync(ct);
+ LogSendFailed(_logger, TruncateResponseBody(retryBody));
return default;
}
@@ -264,8 +265,7 @@ private async Task TryReloginAsync(CancellationToken ct)
Url = "_matrix/client/v3/login"
};
- var json = JsonSerializer.Serialize(loginRequest, MatrixJsonContext.Default.MatrixLoginRequest);
- using var content = new StringContent(json, Encoding.UTF8, "application/json");
+ using var content = Utf8JsonContent.Create(loginRequest, MatrixJsonContext.Default.MatrixLoginRequest);
// Do NOT use CreateRequest here -- we may have an expired/invalid token,
// and the login endpoint does not require Authorization.
using var req = new HttpRequestMessage(HttpMethod.Post, loginRequest.Url) { Content = content };
@@ -274,7 +274,7 @@ private async Task TryReloginAsync(CancellationToken ct)
if (!resp.IsSuccessStatusCode)
{
var body = await resp.Content.ReadAsStringAsync(ct);
- LogReloginFailed(_logger, $"HTTP {(int)resp.StatusCode}: {body}");
+ LogReloginFailed(_logger, $"HTTP {(int)resp.StatusCode}: {TruncateResponseBody(body)}");
return null;
}
@@ -372,7 +372,11 @@ private void SaveSyncToken(string token)
Directory.CreateDirectory(dir);
}
- File.WriteAllText(SyncTokenPath, token);
+ // Atomic write via temp+rename — prevents token corruption on crash
+ // (consistent with SessionManager's File.Move pattern).
+ var tmp = SyncTokenPath + ".tmp";
+ File.WriteAllText(tmp, token);
+ File.Move(tmp, SyncTokenPath, overwrite: true);
}
catch (Exception ex)
{
@@ -508,7 +512,7 @@ private async Task ProcessSyncRoomsAsync(MatrixSyncResponse sync, CancellationTo
// Per-user allowlist check (static AllowFrom + dynamic approved senders)
if (!_allowPolicy.IsAllowed(ev.Sender) &&
- !await _approvedSenders.IsApprovedAsync(ChannelName.Matrix.Value, ev.Sender))
+ !await _approvedSenders.IsApprovedAsync(ChannelName.Matrix.Value, ev.Sender, ct))
{
LogBlockedUser(_logger, ev.Sender);
continue;
@@ -549,6 +553,10 @@ await _bus.PublishAsync(new InboundMessage(
}
}
+ /// Truncates response bodies to avoid logging sensitive data (tokens, session info).
+ private static string TruncateResponseBody(string body, int maxLength = 500) =>
+ body.Length > maxLength ? string.Concat(body.AsSpan(0, maxLength), "...(truncated)") : body;
+
[LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "Starting sync loop")]
private static partial void LogStartingSyncLoop(ILogger logger);
diff --git a/src/clawsharp/Channels/Mattermost/MattermostChannel.cs b/src/clawsharp/Channels/Mattermost/MattermostChannel.cs
index 00820ee4..589b6622 100644
--- a/src/clawsharp/Channels/Mattermost/MattermostChannel.cs
+++ b/src/clawsharp/Channels/Mattermost/MattermostChannel.cs
@@ -1,6 +1,5 @@
using System.Net.Http.Headers;
using System.Net.WebSockets;
-using System.Text;
using System.Text.Json;
using Clawsharp.Config;
using Clawsharp.Core;
@@ -308,17 +307,16 @@ public async Task SendAsync(OutboundMessage message, CancellationToken ct = defa
Message = message.Text
};
- var json = JsonSerializer.Serialize(postReq, MattermostJsonContext.Default.MattermostCreatePostRequest);
-
try
{
using var httpReq = new HttpRequestMessage(HttpMethod.Post, "api/v4/posts");
httpReq.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _botToken);
- httpReq.Content = new StringContent(json, Encoding.UTF8, "application/json");
+ httpReq.Content = Utf8JsonContent.Create(postReq, MattermostJsonContext.Default.MattermostCreatePostRequest);
using var resp = await _http.SendAsync(httpReq, ct);
if (!resp.IsSuccessStatusCode)
{
- LogSendFailed(await resp.Content.ReadAsStringAsync(ct));
+ var body = await resp.Content.ReadAsStringAsync(ct);
+ LogSendFailed(TruncateResponseBody(body));
}
}
catch (Exception ex)
@@ -438,10 +436,9 @@ public async Task StreamAsync(OutboundMessage message, IAsyncEnumerable
ChannelId = channelId,
Message = text
};
- var json = JsonSerializer.Serialize(postReq, MattermostJsonContext.Default.MattermostCreatePostRequest);
using var httpReq = new HttpRequestMessage(HttpMethod.Post, "api/v4/posts");
httpReq.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _botToken);
- httpReq.Content = new StringContent(json, Encoding.UTF8, "application/json");
+ httpReq.Content = Utf8JsonContent.Create(postReq, MattermostJsonContext.Default.MattermostCreatePostRequest);
using var resp = await _http.SendAsync(httpReq, ct);
if (!resp.IsSuccessStatusCode)
@@ -462,15 +459,15 @@ private async Task UpdatePostAsync(string postId, string text, CancellationToken
Id = postId,
Message = text
};
- var json = JsonSerializer.Serialize(updateReq, MattermostJsonContext.Default.MattermostUpdatePostRequest);
using var httpReq = new HttpRequestMessage(HttpMethod.Put, $"api/v4/posts/{postId}");
httpReq.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _botToken);
- httpReq.Content = new StringContent(json, Encoding.UTF8, "application/json");
+ httpReq.Content = Utf8JsonContent.Create(updateReq, MattermostJsonContext.Default.MattermostUpdatePostRequest);
using var resp = await _http.SendAsync(httpReq, ct);
if (!resp.IsSuccessStatusCode)
{
- LogUpdatePostFailed(postId, await resp.Content.ReadAsStringAsync(ct));
+ var body = await resp.Content.ReadAsStringAsync(ct);
+ LogUpdatePostFailed(postId, TruncateResponseBody(body));
}
}
@@ -495,6 +492,10 @@ private async Task FetchSelfIdAsync(CancellationToken ct)
private const int MaxWebSocketMessageBytes = 1 * 1024 * 1024; // 1 MB
+ /// Truncates response bodies to avoid logging sensitive data (tokens, session info).
+ private static string TruncateResponseBody(string body, int maxLength = 500) =>
+ body.Length > maxLength ? string.Concat(body.AsSpan(0, maxLength), "...(truncated)") : body;
+
// ── LoggerMessage methods ────────────────────────────────────────
[LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "Starting Mattermost channel")]
diff --git a/src/clawsharp/Channels/Qq/QqChannel.cs b/src/clawsharp/Channels/Qq/QqChannel.cs
index d0b735ed..d08eecb3 100644
--- a/src/clawsharp/Channels/Qq/QqChannel.cs
+++ b/src/clawsharp/Channels/Qq/QqChannel.cs
@@ -141,7 +141,8 @@ public async Task SendAsync(OutboundMessage message, CancellationToken ct = defa
if (!resp.IsSuccessStatusCode)
{
- LogSendFailed(await resp.Content.ReadAsStringAsync(ct));
+ var responseBody = await resp.Content.ReadAsStringAsync(ct);
+ LogSendFailed(TruncateResponseBody(responseBody));
}
}
else
@@ -150,7 +151,8 @@ public async Task SendAsync(OutboundMessage message, CancellationToken ct = defa
if (!resp.IsSuccessStatusCode)
{
- LogSendFailed(await resp.Content.ReadAsStringAsync(ct));
+ var responseBody = await resp.Content.ReadAsStringAsync(ct);
+ LogSendFailed(TruncateResponseBody(responseBody));
}
}
}
@@ -421,6 +423,10 @@ private Uri BuildWebSocketUri()
return new Uri($"{_wsUrl}{separator}access_token={Uri.EscapeDataString(_token)}");
}
+ /// Truncates response bodies to avoid logging sensitive data (tokens, session info).
+ private static string TruncateResponseBody(string body, int maxLength = 500) =>
+ body.Length > maxLength ? string.Concat(body.AsSpan(0, maxLength), "...(truncated)") : body;
+
// ── LoggerMessage methods ────────────────────────────────────────
[LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "Starting QQ/OneBot channel")]
diff --git a/src/clawsharp/Channels/Signal/SignalChannel.cs b/src/clawsharp/Channels/Signal/SignalChannel.cs
index f8e7559e..6f2f6f95 100644
--- a/src/clawsharp/Channels/Signal/SignalChannel.cs
+++ b/src/clawsharp/Channels/Signal/SignalChannel.cs
@@ -346,9 +346,7 @@ await _bus.PublishAsync(new InboundMessage(
}
};
- var json = JsonSerializer.Serialize(
- rpcRequest, SignalJsonContext.Default.SignalGetAttachmentRpcRequest);
- using var content = new StringContent(json, Encoding.UTF8, "application/json");
+ using var content = Utf8JsonContent.Create(rpcRequest, SignalJsonContext.Default.SignalGetAttachmentRpcRequest);
using var resp = await _http.PostAsync("api/v1/rpc", content, ct);
if (!resp.IsSuccessStatusCode)
@@ -403,8 +401,7 @@ public async Task SendAsync(OutboundMessage message, CancellationToken ct = defa
}
};
- var json = JsonSerializer.Serialize(rpcRequest, SignalJsonContext.Default.SignalSendRpcRequest);
- using var content = new StringContent(json, Encoding.UTF8, "application/json");
+ using var content = Utf8JsonContent.Create(rpcRequest, SignalJsonContext.Default.SignalSendRpcRequest);
try
{
diff --git a/src/clawsharp/Channels/Slack/SlackChannel.cs b/src/clawsharp/Channels/Slack/SlackChannel.cs
index cc0b64d7..c3ea80f8 100644
--- a/src/clawsharp/Channels/Slack/SlackChannel.cs
+++ b/src/clawsharp/Channels/Slack/SlackChannel.cs
@@ -163,14 +163,14 @@ public Task StopThinkingAsync(string recipientId, CancellationToken ct = default
///
private async Task ExecuteAsync(IRequest request, CancellationToken ct)
{
- var json = JsonSerializer.Serialize(request, request.RequestTypeInfo);
using var httpReq = new HttpRequestMessage(HttpMethod.Post, request.Url);
httpReq.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _botToken);
- httpReq.Content = new StringContent(json, Encoding.UTF8, "application/json");
+ httpReq.Content = Utf8JsonContent.Create(request, request.RequestTypeInfo);
using var resp = await _http.SendAsync(httpReq, ct);
if (!resp.IsSuccessStatusCode)
{
- LogSendFailed(_logger, await resp.Content.ReadAsStringAsync(ct));
+ var body = await resp.Content.ReadAsStringAsync(ct);
+ LogSendFailed(_logger, TruncateResponseBody(body));
return default;
}
@@ -220,6 +220,7 @@ await ExecuteAsync(new SlackUpdateMessageRequest
ct: ct).ConfigureAwait(false);
// If the placeholder failed, send as a new message.
+ // result.Text is always raw LLM text (no mrkdwn); SendAsync applies ConvertToMrkdwn.
if (!result.PlaceholderCreated)
{
await SendAsync(message with { Text = result.Text }, ct).ConfigureAwait(false);
@@ -391,7 +392,7 @@ private static (string Text, string UserId, string ChannelId, string? Ts, string
private async Task CheckUserAllowedAsync(string userId, string channelId, JsonElement ev, CancellationToken ct)
{
var isAllowed = _allowPolicy.IsAllowed(userId)
- || await _approvedSenders.IsApprovedAsync("slack", userId);
+ || await _approvedSenders.IsApprovedAsync(ChannelName.Slack.Value, userId, ct);
if (isAllowed)
{
return true;
@@ -408,7 +409,7 @@ private async Task CheckUserAllowedAsync(string userId, string channelId,
{
userName = dn.GetString() ?? userId;
}
- var code = await _pairingStore.GetOrCreateCodeAsync("slack", userId, userName, ct);
+ var code = await _pairingStore.GetOrCreateCodeAsync(ChannelName.Slack.Value, userId, userName, ct);
await PostPairingMessageAsync(userId, code, ct);
LogPairingSent(_logger, userId, code);
}
@@ -481,6 +482,11 @@ internal static string ConvertToMrkdwn(string markdown)
return $"\x00IC{inlineCode.Count - 1}\x00";
});
+ // PERF: The following 7 regex replacements each allocate an intermediate string from the full
+ // response. For a 10KB message this is ~70KB transient allocation. Regex.Replace returns string
+ // (no StringBuilder overload exists) so there is no simple way to chain these without allocation.
+ // Acceptable for per-message Slack formatting — this runs once per outbound message, not per token.
+
// 1. Bold: **text** → *text* (must run before italic to avoid conflict)
result = BoldRegex().Replace(result, "*$1*");
@@ -550,6 +556,10 @@ internal static string ConvertToMrkdwn(string markdown)
[GeneratedRegex(@"\x00IC(\d+)\x00", RegexOptions.None, 200)]
private static partial Regex InlineCodeSentinelRegex();
+ /// Truncates response bodies to avoid logging sensitive data (tokens, session info).
+ private static string TruncateResponseBody(string body, int maxLength = 500) =>
+ body.Length > maxLength ? string.Concat(body.AsSpan(0, maxLength), "...(truncated)") : body;
+
[LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "Starting Socket Mode")]
private static partial void LogStartingSocketMode(ILogger logger);
diff --git a/src/clawsharp/Channels/Telegram/TelegramChannel.cs b/src/clawsharp/Channels/Telegram/TelegramChannel.cs
index a2a6ac2d..cfd324e1 100644
--- a/src/clawsharp/Channels/Telegram/TelegramChannel.cs
+++ b/src/clawsharp/Channels/Telegram/TelegramChannel.cs
@@ -115,6 +115,12 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
while (!stoppingToken.IsCancellationRequested)
{
+ // Retry bot info fetch if it failed at startup
+ if (_botUsername is null)
+ {
+ await FetchBotInfoAsync(stoppingToken).ConfigureAwait(false);
+ }
+
try
{
await _retryPipeline.ExecuteAsync(
@@ -281,7 +287,7 @@ private async Task ProcessUpdateAsync(TelegramUpdate update, CancellationToken c
}
}
- if (!await IsUserAllowedAsync(msg.From))
+ if (!await IsUserAllowedAsync(msg.From, ct))
{
if (_dmPolicy == DmPolicy.Pairing)
{
@@ -571,12 +577,12 @@ public Task StopThinkingAsync(string recipientId, CancellationToken ct = default
///
private async Task ExecuteAsync(IRequest request, CancellationToken ct)
{
- var json = JsonSerializer.Serialize(request, request.RequestTypeInfo);
- using var content = new StringContent(json, Encoding.UTF8, "application/json");
+ using var content = Utf8JsonContent.Create(request, request.RequestTypeInfo);
using var resp = await _http.PostAsync(request.Url, content, ct);
if (!resp.IsSuccessStatusCode)
{
- LogSendFailed(_logger, $"HTTP {(int)resp.StatusCode}: {await resp.Content.ReadAsStringAsync(ct)}");
+ var body = await resp.Content.ReadAsStringAsync(ct);
+ LogSendFailed(_logger, $"HTTP {(int)resp.StatusCode}: {TruncateResponseBody(body)}");
return default;
}
@@ -799,7 +805,7 @@ private static string Normalize(string entry)
return entry.TrimStart('@').Trim();
}
- private async ValueTask IsUserAllowedAsync(TelegramUser user)
+ private async ValueTask IsUserAllowedAsync(TelegramUser user, CancellationToken ct = default)
{
if (_allowPolicy.IsAllowAll)
{
@@ -807,7 +813,7 @@ private async ValueTask IsUserAllowedAsync(TelegramUser user)
}
// Check dynamic approved senders store
- if (await _approvedSenders.IsApprovedAsync(ChannelName.Telegram.Value, user.Id.ToString()))
+ if (await _approvedSenders.IsApprovedAsync(ChannelName.Telegram.Value, user.Id.ToString(), ct))
{
return true;
}
@@ -1036,6 +1042,10 @@ await ExecuteAsync(new TelegramSendMessageRequest
}
}
+ /// Truncates response bodies to avoid logging sensitive data (tokens, session info).
+ private static string TruncateResponseBody(string body, int maxLength = 500) =>
+ body.Length > maxLength ? string.Concat(body.AsSpan(0, maxLength), "...(truncated)") : body;
+
[LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "Starting long-poll loop")]
private static partial void LogStartingLongPollLoop(ILogger logger);
diff --git a/src/clawsharp/Channels/WeChat/WeChatChannel.cs b/src/clawsharp/Channels/WeChat/WeChatChannel.cs
index e41927d5..bfa2f973 100644
--- a/src/clawsharp/Channels/WeChat/WeChatChannel.cs
+++ b/src/clawsharp/Channels/WeChat/WeChatChannel.cs
@@ -1,4 +1,3 @@
-using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using Clawsharp.Config;
@@ -156,8 +155,7 @@ private async Task SendViaWebhookAsync(OutboundMessage message, CancellationToke
Text = new WeChatWebhookText { Content = message.Text }
};
- var json = JsonSerializer.Serialize(req, WeChatJsonContext.Default.WeChatWebhookRequest);
- using var content = new StringContent(json, Encoding.UTF8, "application/json");
+ using var content = Utf8JsonContent.Create(req, WeChatJsonContext.Default.WeChatWebhookRequest);
try
{
diff --git a/src/clawsharp/Channels/WeCom/WeComChannel.cs b/src/clawsharp/Channels/WeCom/WeComChannel.cs
index 0fc7df25..ca9283dc 100644
--- a/src/clawsharp/Channels/WeCom/WeComChannel.cs
+++ b/src/clawsharp/Channels/WeCom/WeComChannel.cs
@@ -2,10 +2,12 @@
using System.Net;
using System.Text;
using System.Text.Json;
+using System.Xml;
using System.Xml.Linq;
using Clawsharp.Config;
using Clawsharp.Core;
using Clawsharp.Core.Services;
+using Clawsharp.Security;
using Clawsharp.Core.Sessions;
using Clawsharp.Core.Utilities;
using Microsoft.Extensions.Logging;
@@ -245,7 +247,9 @@ private async Task HandleMessageAsync(
string? encryptContent;
try
{
- var doc = await XDocument.LoadAsync(ms, LoadOptions.None, ct).ConfigureAwait(false);
+ var xmlSettings = new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null, Async = true };
+ using var xmlReader = XmlReader.Create(ms, xmlSettings);
+ var doc = await XDocument.LoadAsync(xmlReader, LoadOptions.None, ct).ConfigureAwait(false);
toUserName = doc.Root?.Element("ToUserName")?.Value;
encryptContent = doc.Root?.Element("Encrypt")?.Value;
}
@@ -322,8 +326,10 @@ private async Task ProcessBotMessageAsync(WeComBotMessage msg, CancellationToken
return;
}
- // Store response_url for SendAsync
- if (msg.ResponseUrl is not null)
+ // Store response_url for SendAsync — validate via SsrfGuard first.
+ if (msg.ResponseUrl is not null
+ && Uri.TryCreate(msg.ResponseUrl, UriKind.Absolute, out var responseUri)
+ && await SsrfGuard.CheckAsync(responseUri, ct).ConfigureAwait(false) is null)
{
_responseUrls[senderId] = msg.ResponseUrl;
}
@@ -440,8 +446,7 @@ public async Task SendAsync(OutboundMessage message, CancellationToken ct = defa
Text = new WeComReplyText { Content = message.Text }
};
- var json = JsonSerializer.Serialize(reply, WeComBotJsonContext.Default.WeComReplyMessage);
- using var content = new StringContent(json, Encoding.UTF8, "application/json");
+ using var content = Utf8JsonContent.Create(reply, WeComBotJsonContext.Default.WeComReplyMessage);
try
{
@@ -449,7 +454,7 @@ public async Task SendAsync(OutboundMessage message, CancellationToken ct = defa
if (!resp.IsSuccessStatusCode)
{
var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
- LogSendError(body);
+ LogSendError(TruncateResponseBody(body));
}
}
catch (Exception ex)
@@ -458,6 +463,10 @@ public async Task SendAsync(OutboundMessage message, CancellationToken ct = defa
}
}
+ /// Truncates response bodies to avoid logging sensitive data (tokens, session info).
+ private static string TruncateResponseBody(string body, int maxLength = 500) =>
+ body.Length > maxLength ? string.Concat(body.AsSpan(0, maxLength), "...(truncated)") : body;
+
// ── LoggerMessage methods ────────────────────────────────────────────
[LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "Starting WeCom AI Bot webhook listener on port {Port}")]
diff --git a/src/clawsharp/Channels/Web/WebChannel.Oidc.cs b/src/clawsharp/Channels/Web/WebChannel.Oidc.cs
index 195e71ce..5887af13 100644
--- a/src/clawsharp/Channels/Web/WebChannel.Oidc.cs
+++ b/src/clawsharp/Channels/Web/WebChannel.Oidc.cs
@@ -165,7 +165,7 @@ private async Task HandleOidcCallbackAsync(HttpContext context, CancellationToke
var message = resolveResult.Message ?? "Identity resolution failed.";
LogOidcIdentityDenied(_logger, message);
context.Response.StatusCode = StatusCodes.Status403Forbidden;
- await context.Response.WriteAsync(message, ct);
+ await context.Response.WriteAsync("Access denied. Contact your administrator.", ct);
DeleteStateCookie(context);
return;
}
@@ -202,14 +202,14 @@ private async Task HandleLinkCallbackAsync(HttpContext context, CancellationToke
return;
}
- // Validate link token (but don't consume it yet — that happens after OIDC callback)
- // We peek at the token to verify it exists and is valid before redirecting to IdP.
- // The actual consumption happens in CompleteLinkFlowAsync.
- // NOTE: LinkTokenStore.Validate is destructive (TryRemove). We need to re-store
- // the token temporarily or validate non-destructively. Since LinkTokenStore uses
- // TryRemove for single-use, we validate the signature manually here and consume in callback.
- // For now, we trust the token format and signature will be validated at callback time.
- // The link token + sig are passed through the state cookie to the callback.
+ // Validate link token non-destructively before redirecting to IdP.
+ // Consumption happens in CompleteLinkFlowAsync after the OIDC round-trip.
+ if (!_linkTokenStore.Peek(linkToken, linkSig))
+ {
+ context.Response.StatusCode = StatusCodes.Status400BadRequest;
+ await context.Response.WriteAsync("Invalid or expired link token.", ct).ConfigureAwait(false);
+ return;
+ }
var (state, nonce) = OidcService.GenerateStateAndNonce();
var (codeVerifier, codeChallenge) = OidcService.GeneratePkce();
@@ -266,7 +266,7 @@ private async Task CompleteLinkFlowAsync(
var message = resolveResult.Message ?? "Identity resolution failed.";
LogOidcLinkDenied(_logger, message);
context.Response.StatusCode = StatusCodes.Status403Forbidden;
- await context.Response.WriteAsync(message, ct);
+ await context.Response.WriteAsync("Access denied. Contact your administrator.", ct);
return;
}
diff --git a/src/clawsharp/Channels/Web/WebChannel.cs b/src/clawsharp/Channels/Web/WebChannel.cs
index cbac2743..27f9a925 100644
--- a/src/clawsharp/Channels/Web/WebChannel.cs
+++ b/src/clawsharp/Channels/Web/WebChannel.cs
@@ -571,7 +571,15 @@ private async Task HandleHttpChatAsync(HttpContext context, CancellationToken ct
// Per Pitfall #3: cookie-authenticated users derive ID from OIDC sub claim.
var sessionId = DeriveSessionIdFromContext(context);
var tcs = new TaskCompletionSource();
- _pending[sessionId] = tcs;
+
+ // Reject concurrent requests for the same session — indexer overwrite would
+ // silently abandon the first TCS, causing an HTTP 500 timeout.
+ if (!_pending.TryAdd(sessionId, tcs))
+ {
+ context.Response.StatusCode = StatusCodes.Status409Conflict;
+ await context.Response.WriteAsync("A request is already in progress for this session.", ct).ConfigureAwait(false);
+ return;
+ }
try
{
@@ -683,6 +691,22 @@ private async Task HandleWebSocketAsync(WebSocket ws, IPAddress? remoteIp, Cance
///
private async Task RunWebSocketMessageLoopAsync(WebSocket ws, string sessionId, IPAddress? remoteIp, CancellationToken ct)
{
+ // Close the previous connection if a new one authenticates with the same session,
+ // preventing delivery hijack where replies go to the new connection while the old
+ // connection's loop still publishes inbound messages.
+ if (_wsClients.TryGetValue(sessionId, out var existing) && existing.State == WebSocketState.Open)
+ {
+ try
+ {
+ await existing.CloseAsync(WebSocketCloseStatus.NormalClosure, "Replaced by new connection", ct)
+ .ConfigureAwait(false);
+ }
+ catch
+ {
+ // Best-effort close — the old connection may already be broken.
+ }
+ }
+
_wsClients[sessionId] = ws;
var buffer = ArrayPool.Shared.Rent(WebSocketReceiveBufferSize);
try
diff --git a/src/clawsharp/Channels/Web/index.html b/src/clawsharp/Channels/Web/index.html
index e82e9379..c1df0571 100644
--- a/src/clawsharp/Channels/Web/index.html
+++ b/src/clawsharp/Channels/Web/index.html
@@ -10,8 +10,8 @@
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;1,400&display=swap"
rel="stylesheet"
/>
-
+
diff --git a/src/clawsharp/Cli/AgentCommand.cs b/src/clawsharp/Cli/AgentCommand.cs
index 8e44f8ab..f11736b3 100644
--- a/src/clawsharp/Cli/AgentCommand.cs
+++ b/src/clawsharp/Cli/AgentCommand.cs
@@ -28,11 +28,11 @@ public override async Task ExecuteAsync(CommandContext context, Settings se
{
if (settings.Message is not null)
{
- await SingleShotCommand.RunAsync(settings.Message, cancellationToken);
+ await SingleShotCommand.RunAsync(settings.Message, cancellationToken).ConfigureAwait(false);
return 0;
}
- await GatewayHost.RunAsync(cancellationToken);
+ await GatewayHost.RunAsync(cancellationToken).ConfigureAwait(false);
return 0;
}
}
\ No newline at end of file
diff --git a/src/clawsharp/Cli/Auth/AuthLoginCopilotCommand.cs b/src/clawsharp/Cli/Auth/AuthLoginCopilotCommand.cs
index ad319449..67027a90 100644
--- a/src/clawsharp/Cli/Auth/AuthLoginCopilotCommand.cs
+++ b/src/clawsharp/Cli/Auth/AuthLoginCopilotCommand.cs
@@ -16,14 +16,14 @@ public override async Task ExecuteAsync(CommandContext context, Cancellatio
AnsiConsole.MarkupLine("Your GitHub account must have an active Copilot subscription.");
AnsiConsole.WriteLine();
- var token = await deviceFlow.LoginAsync(cancellationToken);
+ var token = await deviceFlow.LoginAsync(cancellationToken).ConfigureAwait(false);
if (token is null)
{
AnsiConsole.MarkupLine("[red]Login failed.[/]");
return 1;
}
- await AuthStore.SaveAsync("copilot", token, cancellationToken);
+ await AuthStore.SaveAsync("copilot", token, cancellationToken).ConfigureAwait(false);
AnsiConsole.MarkupLine("[green]Logged in to GitHub Copilot successfully.[/]");
if (token.ExpiresAt.HasValue)
{
diff --git a/src/clawsharp/Cli/Auth/AuthStatusCommand.cs b/src/clawsharp/Cli/Auth/AuthStatusCommand.cs
index f3f95f65..81d82cf8 100644
--- a/src/clawsharp/Cli/Auth/AuthStatusCommand.cs
+++ b/src/clawsharp/Cli/Auth/AuthStatusCommand.cs
@@ -22,7 +22,7 @@ public override async Task ExecuteAsync(CommandContext context, Cancellatio
foreach (var provider in KnownProviders)
{
- var token = await AuthStore.LoadAsync(provider, cancellationToken);
+ var token = await AuthStore.LoadAsync(provider, cancellationToken).ConfigureAwait(false);
if (token is null)
{
table.AddRow(provider, "[grey]Not logged in[/]", "-");
diff --git a/src/clawsharp/Cli/Channel/ChannelPairWebCommand.cs b/src/clawsharp/Cli/Channel/ChannelPairWebCommand.cs
index 39c4e943..a67d5c8e 100644
--- a/src/clawsharp/Cli/Channel/ChannelPairWebCommand.cs
+++ b/src/clawsharp/Cli/Channel/ChannelPairWebCommand.cs
@@ -31,7 +31,7 @@ public override async Task ExecuteAsync(CommandContext context, Cancellatio
{
using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
connectCts.CancelAfter(TimeSpan.FromSeconds(3));
- await pipe.ConnectAsync(connectCts.Token);
+ await pipe.ConnectAsync(connectCts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
@@ -50,11 +50,11 @@ public override async Task ExecuteAsync(CommandContext context, Cancellatio
await using var writer = new StreamWriter(pipe, Encoding.UTF8, leaveOpen: true) { AutoFlush = true };
var reqJson = JsonSerializer.Serialize(new IpcRequest(command, token), IpcJsonContext.Default.IpcRequest);
- await writer.WriteLineAsync(reqJson.AsMemory(), cancellationToken);
+ await writer.WriteLineAsync(reqJson.AsMemory(), cancellationToken).ConfigureAwait(false);
using var readCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
readCts.CancelAfter(TimeSpan.FromSeconds(5));
- var line = await reader.ReadLineAsync(readCts.Token);
+ var line = await reader.ReadLineAsync(readCts.Token).ConfigureAwait(false);
if (line is null)
{
diff --git a/src/clawsharp/Cli/Config/ConfigSetCommand.cs b/src/clawsharp/Cli/Config/ConfigSetCommand.cs
index 16b71646..b9088169 100644
--- a/src/clawsharp/Cli/Config/ConfigSetCommand.cs
+++ b/src/clawsharp/Cli/Config/ConfigSetCommand.cs
@@ -61,7 +61,7 @@ public override async Task ExecuteAsync(CommandContext context, Settings se
JsonNode? root = null;
if (File.Exists(configPath))
{
- var json = await File.ReadAllTextAsync(configPath, cancellationToken);
+ var json = await File.ReadAllTextAsync(configPath, cancellationToken).ConfigureAwait(false);
root = JsonNode.Parse(json);
}
@@ -102,6 +102,12 @@ public override async Task ExecuteAsync(CommandContext context, Settings se
value = store.Encrypt(value);
}
+ if (settings.Type is not null && settings.Type.ToLowerInvariant() is not ("string" or "int" or "bool"))
+ {
+ AnsiConsole.MarkupLine($"[red]Error:[/] Unsupported type '{Markup.Escape(settings.Type)}'. Supported: string, int, bool.");
+ return 1;
+ }
+
var typed = DetectTypedValue(value, settings.Type);
if (typed is null)
{
@@ -113,7 +119,7 @@ public override async Task ExecuteAsync(CommandContext context, Settings se
var output = root.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
var tempPath = configPath + ".tmp";
- await File.WriteAllTextAsync(tempPath, output, cancellationToken);
+ await File.WriteAllTextAsync(tempPath, output, cancellationToken).ConfigureAwait(false);
File.Move(tempPath, configPath, overwrite: true);
AnsiConsole.MarkupLine($"[green]Set[/] [cyan]{Markup.Escape(key)}[/] in [grey]~/.clawsharp/config.json[/]");
diff --git a/src/clawsharp/Cli/Config/EncryptSecretsCommand.cs b/src/clawsharp/Cli/Config/EncryptSecretsCommand.cs
index 30156df3..70424c98 100644
--- a/src/clawsharp/Cli/Config/EncryptSecretsCommand.cs
+++ b/src/clawsharp/Cli/Config/EncryptSecretsCommand.cs
@@ -15,7 +15,7 @@ public sealed class EncryptSecretsCommand : AsyncCommand
// Fields that hold secrets in config.json
private static readonly IReadOnlySet SecretFields = KnownSecretFields.All;
- public override Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
+ public override async Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
{
var config = ClawsharpConfiguration.GetAppConfig();
var store = new SecretStore(Microsoft.Extensions.Options.Options.Create(config));
@@ -24,24 +24,25 @@ public override Task ExecuteAsync(CommandContext context, CancellationToken
if (!File.Exists(configPath))
{
AnsiConsole.MarkupLine("[yellow]No config file found at {0}.[/]", Markup.Escape(configPath));
- return Task.FromResult(1);
+ return 1;
}
- var json = File.ReadAllText(configPath);
+ var json = await File.ReadAllTextAsync(configPath, cancellationToken).ConfigureAwait(false);
var root = JsonNode.Parse(json) as JsonObject;
if (root is null)
{
AnsiConsole.MarkupLine("[red]Config file is not valid JSON.[/]");
- return Task.FromResult(1);
+ return 1;
}
var count = EncryptNode(root, store);
var tempPath = configPath + ".tmp";
- File.WriteAllText(tempPath, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
+ await File.WriteAllTextAsync(tempPath,
+ root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }), cancellationToken).ConfigureAwait(false);
File.Move(tempPath, configPath, overwrite: true);
AnsiConsole.MarkupLine($"[green]Encrypted {count} secret field(s) in {Markup.Escape(configPath)}.[/]");
- return Task.FromResult(0);
+ return 0;
}
private static int EncryptNode(JsonNode node, SecretStore store)
diff --git a/src/clawsharp/Cli/Cron/CronAddCommand.cs b/src/clawsharp/Cli/Cron/CronAddCommand.cs
index 92265382..cae5ddc4 100644
--- a/src/clawsharp/Cli/Cron/CronAddCommand.cs
+++ b/src/clawsharp/Cli/Cron/CronAddCommand.cs
@@ -75,8 +75,8 @@ public override async Task ExecuteAsync(CommandContext context, Settings se
var config = ClawsharpConfiguration.GetAppConfig();
var store = CronStoreFactory.Create(config);
- await store.InitAsync(cancellationToken);
- await store.UpsertAsync(job, cancellationToken);
+ await store.InitAsync(cancellationToken).ConfigureAwait(false);
+ await store.UpsertAsync(job, cancellationToken).ConfigureAwait(false);
AnsiConsole.MarkupLine($"[green]Created[/] cron job [cyan]{job.Id[..8]}[/] " +
$"(kind=[bold]{Markup.Escape(kind.Value)}[/], expr=[bold]{Markup.Escape(expr)}[/], " +
diff --git a/src/clawsharp/Cli/Cron/CronListCommand.cs b/src/clawsharp/Cli/Cron/CronListCommand.cs
index 1542c740..eea285de 100644
--- a/src/clawsharp/Cli/Cron/CronListCommand.cs
+++ b/src/clawsharp/Cli/Cron/CronListCommand.cs
@@ -14,8 +14,8 @@ public override async Task ExecuteAsync(CommandContext context, Cancellatio
{
var config = ClawsharpConfiguration.GetAppConfig();
var store = CronStoreFactory.Create(config);
- await store.InitAsync(cancellationToken);
- var jobs = await store.LoadAllAsync(cancellationToken);
+ await store.InitAsync(cancellationToken).ConfigureAwait(false);
+ var jobs = await store.LoadAllAsync(cancellationToken).ConfigureAwait(false);
if (jobs.Count == 0)
{
diff --git a/src/clawsharp/Cli/Cron/CronRemoveCommand.cs b/src/clawsharp/Cli/Cron/CronRemoveCommand.cs
index 6f8d222b..7129c04b 100644
--- a/src/clawsharp/Cli/Cron/CronRemoveCommand.cs
+++ b/src/clawsharp/Cli/Cron/CronRemoveCommand.cs
@@ -23,8 +23,8 @@ public override async Task ExecuteAsync(CommandContext context, Settings se
{
var config = ClawsharpConfiguration.GetAppConfig();
var store = CronStoreFactory.Create(config);
- await store.InitAsync(cancellationToken);
- var jobs = await store.LoadAllAsync(cancellationToken);
+ await store.InitAsync(cancellationToken).ConfigureAwait(false);
+ var jobs = await store.LoadAllAsync(cancellationToken).ConfigureAwait(false);
var match = jobs.FirstOrDefault(j => j.Id.StartsWith(settings.Id, StringComparison.OrdinalIgnoreCase));
if (match is null)
@@ -33,7 +33,7 @@ public override async Task ExecuteAsync(CommandContext context, Settings se
return 1;
}
- await store.DeleteAsync(match.Id, cancellationToken);
+ await store.DeleteAsync(match.Id, cancellationToken).ConfigureAwait(false);
var shortId = match.Id[..Math.Min(8, match.Id.Length)];
AnsiConsole.MarkupLine($"[green]Removed[/] cron job [cyan]{shortId}[/] ([grey]{Markup.Escape(match.Name ?? "(unnamed)")}[/])");
return 0;
diff --git a/src/clawsharp/Cli/Cron/CronRunCommand.cs b/src/clawsharp/Cli/Cron/CronRunCommand.cs
index a355f858..759d8986 100644
--- a/src/clawsharp/Cli/Cron/CronRunCommand.cs
+++ b/src/clawsharp/Cli/Cron/CronRunCommand.cs
@@ -23,8 +23,8 @@ public override async Task ExecuteAsync(CommandContext context, Settings se
{
var config = ClawsharpConfiguration.GetAppConfig();
var store = CronStoreFactory.Create(config);
- await store.InitAsync(cancellationToken);
- var jobs = await store.LoadAllAsync(cancellationToken);
+ await store.InitAsync(cancellationToken).ConfigureAwait(false);
+ var jobs = await store.LoadAllAsync(cancellationToken).ConfigureAwait(false);
var match = jobs.FirstOrDefault(j => j.Id.StartsWith(settings.Id, StringComparison.OrdinalIgnoreCase));
if (match is null)
diff --git a/src/clawsharp/Cli/DoctorCommand.cs b/src/clawsharp/Cli/DoctorCommand.cs
index 92983bc5..ac93a69d 100644
--- a/src/clawsharp/Cli/DoctorCommand.cs
+++ b/src/clawsharp/Cli/DoctorCommand.cs
@@ -48,9 +48,9 @@ public override async Task ExecuteAsync(CommandContext context, Settings se
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine("[bold]Deep checks:[/]");
- failures += await CheckProviderConnectivity(config, cancellationToken);
- failures += await CheckDatabaseConnectivity(config, cancellationToken);
- warnings += await CheckWorkspaceWritability(workspace, cancellationToken);
+ failures += await CheckProviderConnectivity(config, cancellationToken).ConfigureAwait(false);
+ failures += await CheckDatabaseConnectivity(config, cancellationToken).ConfigureAwait(false);
+ warnings += await CheckWorkspaceWritability(workspace, cancellationToken).ConfigureAwait(false);
CheckSystemMd(workspace, ref warnings);
CheckBraveSearch(config, ref warnings);
Ok($".NET {Environment.Version}");
diff --git a/src/clawsharp/Cli/GatewayCommand.cs b/src/clawsharp/Cli/GatewayCommand.cs
index 5dfc8024..cb2725f4 100644
--- a/src/clawsharp/Cli/GatewayCommand.cs
+++ b/src/clawsharp/Cli/GatewayCommand.cs
@@ -17,7 +17,7 @@ public sealed class GatewayCommand : AsyncCommand
Justification = "Spectre.Console.Cli already requires dynamic code. EF Core types are statically rooted in this project.")]
public override async Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
{
- await GatewayHost.RunAsync(cancellationToken);
+ await GatewayHost.RunAsync(cancellationToken).ConfigureAwait(false);
return 0;
}
}
\ No newline at end of file
diff --git a/src/clawsharp/Cli/GatewayHost.cs b/src/clawsharp/Cli/GatewayHost.cs
index ae439bfb..947575b7 100644
--- a/src/clawsharp/Cli/GatewayHost.cs
+++ b/src/clawsharp/Cli/GatewayHost.cs
@@ -120,9 +120,14 @@ public static async Task RunAsync(CancellationToken ct = default)
ApplyLandlockSandbox(appConfig);
- appConfig.Channels.TryGetValue("discord", out var discordCfg);
+ appConfig.Channels.TryGetValue(ChannelName.Discord.Value, out var discordCfg);
var discordEnabled = discordCfg is { Enabled: true, Token: not null };
+ // Pre-load knowledge plugins before host construction so the async
+ // verification path is properly awaited instead of blocked via
+ // GetAwaiter().GetResult() inside the synchronous ConfigureServices callback.
+ var knowledgePlugins = await LoadKnowledgePluginsAsync(appConfig).ConfigureAwait(false);
+
var hostBuilder = Host.CreateDefaultBuilder(Array.Empty())
.ConfigureLogging(logging => ConfigureLogging(logging, appConfig.Telemetry))
.AddClawsharpTelemetry(appConfig.Telemetry)
@@ -132,7 +137,7 @@ public static async Task RunAsync(CancellationToken ct = default)
var webProxy = CreateProxy(appConfig);
ConfigureHostOptions(services);
- AddLlmHttpClient(services, appConfig, webProxy);
+ AddLlmHttpClient(services, appConfig, ssrfConnectCallback, webProxy);
AddToolAndTranscriptionHttpClients(services, ssrfConnectCallback, webProxy);
AddChannelHttpClients(services, appConfig, ssrfConnectCallback, webProxy);
services.AddChannelResiliencePipelines(appConfig.Channels);
@@ -140,9 +145,10 @@ public static async Task RunAsync(CancellationToken ct = default)
RegisterEmbeddingProvider(services, appConfig);
RegisterMemoryBackend(services, appConfig);
RegisterKnowledgeStore(services, appConfig);
- RegisterDocumentLoaders(services, appConfig, configuration);
+ RegisterDocumentLoaders(services, appConfig, configuration, knowledgePlugins);
RegisterIngestionPipeline(services, appConfig);
- RegisterReranker(services, appConfig);
+ var rerankerHandler = CreateHandlerFactory(ssrfConnectCallback, webProxy, useProxy: true);
+ RegisterReranker(services, appConfig, rerankerHandler);
RegisterProviderFactory(services, appConfig);
RegisterConditionalHostedServices(services, appConfig);
RegisterSharedAuthServices(services, appConfig);
@@ -164,7 +170,7 @@ public static async Task RunAsync(CancellationToken ct = default)
ConfigureDiscord(hostBuilder, discordCfg!);
}
- await hostBuilder.RunConsoleAsync(ct);
+ await hostBuilder.RunConsoleAsync(ct).ConfigureAwait(false);
}
///
@@ -174,7 +180,7 @@ public static async Task RunAsync(CancellationToken ct = default)
///
[RequiresUnreferencedCode("Creates EF Core DbContext instances which use reflection for model building.")]
[RequiresDynamicCode("Creates EF Core DbContext instances which require dynamic code for query compilation.")]
- internal static ServiceProvider BuildKnowledgeServiceProvider(AppConfig appConfig)
+ internal static async Task BuildKnowledgeServiceProviderAsync(AppConfig appConfig)
{
var configuration = ClawsharpConfiguration.Build();
var services = new ServiceCollection();
@@ -187,11 +193,13 @@ internal static ServiceProvider BuildKnowledgeServiceProvider(AppConfig appConfi
// Options
services.AddSingleton>(new OptionsWrapper(appConfig));
+ var plugins = await LoadKnowledgePluginsAsync(appConfig).ConfigureAwait(false);
+
// Embedding, memory, knowledge store, document loaders, ingestion pipeline
RegisterEmbeddingProvider(services, appConfig);
RegisterMemoryBackend(services, appConfig);
RegisterKnowledgeStore(services, appConfig);
- RegisterDocumentLoaders(services, appConfig, configuration);
+ RegisterDocumentLoaders(services, appConfig, configuration, plugins);
RegisterIngestionPipeline(services, appConfig);
RegisterReranker(services, appConfig);
@@ -224,6 +232,13 @@ private static void ApplyLandlockSandbox(AppConfig appConfig)
using var landlockLoggerFactory = LoggerFactory.Create(b => b.AddConsole().SetMinimumLevel(LogLevel.Information));
var landlockLogger = landlockLoggerFactory.CreateLogger("Landlock");
+
+ if (!(appConfig.Security?.Landlock?.Enabled ?? false))
+ {
+ landlockLogger.LogInformation(
+ "Landlock filesystem sandbox is not enabled — consider enabling security.landlock.enabled for defense-in-depth");
+ }
+
var shellEnabled = appConfig.Tools.ShellEnabled;
LandlockSandbox.Apply(appConfig.Security?.Landlock ?? new LandlockConfig(), landlockLogger, shellEnabled);
}
@@ -336,6 +351,7 @@ private static Func CreateHandlerFactory
private static void AddLlmHttpClient(
IServiceCollection services,
AppConfig appConfig,
+ Func> ssrfConnectCallback,
System.Net.WebProxy? webProxy)
{
var resilience = appConfig.Agents.Defaults.Resilience;
@@ -346,6 +362,7 @@ private static void AddLlmHttpClient(
.ConfigurePrimaryHttpMessageHandler(() =>
{
var h = new SocketsHttpHandler();
+ h.ConnectCallback = ssrfConnectCallback;
if (webProxy is not null)
{
h.Proxy = webProxy;
@@ -428,6 +445,9 @@ private static void AddChannelHttpClients(
{
var noProxyHandler = CreateHandlerFactory(ssrfConnectCallback, webProxy, useProxy: false);
+ // OIDC token exchange — 30s timeout, SSRF-protected.
+ AddSsrfSafeHttpClient(services, noProxyHandler, "oidc", timeoutSeconds: 30);
+
// Telegram — 35 s timeout (> 30 s long-poll).
AddSsrfSafeHttpClient(services, noProxyHandler, "telegram", timeoutSeconds: 35, configure: client =>
client.BaseAddress = new Uri(ClawsharpConstants.TelegramBaseUrl));
@@ -494,7 +514,7 @@ private static void AddChannelHttpClients(
// Lark/Feishu — domain determined by feishuDomain config.
AddSsrfSafeHttpClient(services, noProxyHandler, "lark", configure: client =>
{
- if (!appConfig.Channels.TryGetValue("lark", out var larkCfg))
+ if (!appConfig.Channels.TryGetValue(ChannelName.Lark.Value, out var larkCfg))
{
return;
}
@@ -752,7 +772,46 @@ internal static void RegisterKnowledgeStore(IServiceCollection services, AppConf
/// Registers the five built-in document loaders and the DocumentLoaderRegistry
/// for the knowledge ingestion pipeline per D-31. Only registers when knowledge is enabled.
///
- internal static void RegisterDocumentLoaders(IServiceCollection services, AppConfig appConfig, IConfiguration configuration)
+ ///
+ /// Loads knowledge plugins asynchronously, including integrity verification when configured.
+ /// Returns empty list if knowledge is not enabled or no plugins directory exists.
+ /// Called before host construction so the async load is properly awaited instead of blocked.
+ ///
+ internal static async Task> LoadKnowledgePluginsAsync(AppConfig appConfig)
+ {
+ if (appConfig.Knowledge is not { Enabled: true })
+ return Array.Empty();
+
+ var pluginsPath = appConfig.Knowledge.PluginsPath
+ ?? Path.Combine(AppContext.BaseDirectory, "plugins");
+
+ using var pluginLoggerFactory = LoggerFactory.Create(
+ b => b.AddConsole().SetMinimumLevel(LogLevel.Information));
+ var pluginLogger = pluginLoggerFactory.CreateLogger("PluginLoader");
+
+ if (appConfig.Knowledge.RequireSignedPlugins)
+ {
+ // D-35: Integrity verification BEFORE assembly loading.
+ var auditLogger = new AuditLogger(
+ Options.Create(appConfig),
+ pluginLoggerFactory.CreateLogger());
+ var verifier = new PluginIntegrityVerifier(
+ auditLogger,
+ appConfig.Knowledge,
+ pluginLoggerFactory.CreateLogger());
+ return await PluginLoader.LoadPluginsAsync(
+ pluginsPath, verifier, requireSigned: true,
+ pluginLogger).ConfigureAwait(false);
+ }
+
+ pluginLogger.LogWarning(
+ "Plugin signature verification is disabled — loading unsigned plugins from {PluginsPath}",
+ pluginsPath);
+ return PluginLoader.LoadPlugins(pluginsPath, pluginLogger);
+ }
+
+ internal static void RegisterDocumentLoaders(
+ IServiceCollection services, AppConfig appConfig, IConfiguration configuration, IReadOnlyList plugins)
{
if (appConfig.Knowledge is not { Enabled: true })
{
@@ -766,20 +825,12 @@ internal static void RegisterDocumentLoaders(IServiceCollection services, AppCon
services.AddSingleton();
services.AddSingleton();
- // Plugin system: discover and load plugin DLLs from plugins/ directory (PLUG-01 through PLUG-04)
- var pluginsPath = appConfig.Knowledge.PluginsPath
- ?? Path.Combine(AppContext.BaseDirectory, "plugins");
-
- var plugins = PluginLoader.LoadPluginsAsync(
- pluginsPath, verifier: null, requireSigned: false,
- NullLogger.Instance).GetAwaiter().GetResult();
-
- // Each plugin registers its IDocumentLoader implementations + supporting services (D-08)
- foreach (var plugin in plugins)
- {
- var section = configuration.GetSection($"knowledge:plugins:{plugin.Name}");
- plugin.ConfigureServices(services, section);
- }
+ // Each plugin registers its IDocumentLoader implementations + supporting services (D-08).
+ // Fault-tolerant: failures are logged and skipped (D-04/D-05).
+ using var pluginLoggerFactory = LoggerFactory.Create(
+ b => b.AddConsole().SetMinimumLevel(LogLevel.Information));
+ PluginLoader.RegisterPluginServices(plugins, services, configuration,
+ pluginLoggerFactory.CreateLogger("PluginLoader"));
// D-31: Registry collects all IDocumentLoader from DI and indexes by extension
services.AddSingleton();
@@ -810,7 +861,7 @@ internal static void RegisterIngestionPipeline(IServiceCollection services, AppC
if (embeddingProvider is IBatchEmbeddingProvider nativeBatch)
return nativeBatch;
- var batchConfig = appConfig.Knowledge.Embedding ?? new Clawsharp.Knowledge.Config.EmbeddingBatchConfig();
+ var batchConfig = appConfig.Knowledge.Embedding ?? new EmbeddingBatchConfig();
var logger = sp.GetRequiredService>();
return new BatchEmbeddingProvider(embeddingProvider, batchConfig, logger);
});
@@ -823,11 +874,11 @@ internal static void RegisterIngestionPipeline(IServiceCollection services, AppC
Func>? factory = appConfig.Memory.Backend switch
{
var b when b == MemoryBackend.Sqlite.Value =>
- async ct => await sp.GetRequiredService>().CreateDbContextAsync(ct),
+ async ct => await sp.GetRequiredService>().CreateDbContextAsync(ct).ConfigureAwait(false),
var b when b == MemoryBackend.Postgres.Value =>
- async ct => await sp.GetRequiredService>().CreateDbContextAsync(ct),
+ async ct => await sp.GetRequiredService>().CreateDbContextAsync(ct).ConfigureAwait(false),
var b when b == MemoryBackend.MsSql.Value =>
- async ct => await sp.GetRequiredService>().CreateDbContextAsync(ct),
+ async ct => await sp.GetRequiredService>().CreateDbContextAsync(ct).ConfigureAwait(false),
_ => null, // Redis, Markdown — no EF-based CAS
};
return new SyncStateTracker(factory, logger);
@@ -851,7 +902,10 @@ internal static void RegisterIngestionPipeline(IServiceCollection services, AppC
/// Only registers when knowledge.enabled is true; otherwise no IReranker in DI
/// (ToolRegistry handles null IReranker gracefully).
///
- internal static void RegisterReranker(IServiceCollection services, AppConfig appConfig)
+ internal static void RegisterReranker(
+ IServiceCollection services,
+ AppConfig appConfig,
+ Func? ssrfHandlerFactory = null)
{
if (appConfig.Knowledge is not { Enabled: true })
{
@@ -869,11 +923,19 @@ internal static void RegisterReranker(IServiceCollection services, AppConfig app
if (string.Equals(rerankerConfig.Provider, "cohere", StringComparison.OrdinalIgnoreCase))
{
- // D-25: Named HTTP client with 10s timeout
- services.AddHttpClient("cohere-reranker", client =>
+ // M-11: Use SSRF-safe HTTP client when handler factory is available (host path).
+ // CLI ingestion path passes null — falls back to plain AddHttpClient.
+ if (ssrfHandlerFactory is not null)
{
- client.Timeout = TimeSpan.FromSeconds(10);
- });
+ AddSsrfSafeHttpClient(services, ssrfHandlerFactory, "cohere-reranker", timeoutSeconds: 10);
+ }
+ else
+ {
+ services.AddHttpClient("cohere-reranker", client =>
+ {
+ client.Timeout = TimeSpan.FromSeconds(10);
+ });
+ }
services.AddSingleton(sp =>
{
@@ -911,9 +973,12 @@ private static void RegisterProviderFactory(IServiceCollection services, AppConf
catch (Exception ex)
{
LogProviderFallback(initLogger, ex);
- opts.Providers["ollama"] = new ProviderConfig
- { Type = "ollama", BaseUrl = ClawsharpConstants.OllamaDefaultBaseUrl };
- return ProviderFactory.Create("ollama", opts.Providers, httpFactory);
+ var fallbackProviders = new Dictionary(opts.Providers)
+ {
+ ["ollama"] = new ProviderConfig
+ { Type = "ollama", BaseUrl = ClawsharpConstants.OllamaDefaultBaseUrl }
+ };
+ return ProviderFactory.Create("ollama", fallbackProviders, httpFactory);
}
});
@@ -972,8 +1037,7 @@ internal static void RegisterMcpServerMode(IServiceCollection services, AppConfi
services.AddSingleton(sp => new McpServerAuthenticator(
appConfig.McpServer,
- sp.GetRequiredService(),
- sp.GetRequiredService>()));
+ sp.GetRequiredService()));
services.AddSingleton();
services.AddSingleton();
services.AddSingleton(
@@ -1280,83 +1344,83 @@ bool IsChannelEnabled(string key) =>
AddChannel(services);
- if (IsChannelEnabled("web"))
+ if (IsChannelEnabled(ChannelName.Web.Value))
{
AddChannel(services);
services.AddSingleton(sp => sp.GetRequiredService());
}
- if (IsChannelEnabled("telegram"))
+ if (IsChannelEnabled(ChannelName.Telegram.Value))
{
AddChannel(services);
}
- if (IsChannelEnabled("slack"))
+ if (IsChannelEnabled(ChannelName.Slack.Value))
{
AddChannel(services);
}
- if (IsChannelEnabled("matrix"))
+ if (IsChannelEnabled(ChannelName.Matrix.Value))
{
AddChannel(services);
}
- if (IsChannelEnabled("email"))
+ if (IsChannelEnabled(ChannelName.Email.Value))
{
AddChannel(services);
}
- if (IsChannelEnabled("irc"))
+ if (IsChannelEnabled(ChannelName.Irc.Value))
{
AddChannel(services);
}
- if (IsChannelEnabled("mattermost"))
+ if (IsChannelEnabled(ChannelName.Mattermost.Value))
{
AddChannel(services);
}
- if (IsChannelEnabled("nostr"))
+ if (IsChannelEnabled(ChannelName.Nostr.Value))
{
AddChannel(services);
}
- if (IsChannelEnabled("qq"))
+ if (IsChannelEnabled(ChannelName.Qq.Value))
{
AddChannel(services);
}
- if (IsChannelEnabled("signal"))
+ if (IsChannelEnabled(ChannelName.Signal.Value))
{
AddChannel(services);
}
- if (IsChannelEnabled("whatsapp"))
+ if (IsChannelEnabled(ChannelName.WhatsApp.Value))
{
AddChannel(services);
}
- if (IsChannelEnabled("wechat"))
+ if (IsChannelEnabled(ChannelName.WeChat.Value))
{
AddChannel(services);
}
- if (IsChannelEnabled("bluebubbles"))
+ if (IsChannelEnabled(ChannelName.BlueBubbles.Value))
{
AddChannel(services);
}
- if (IsChannelEnabled("line"))
+ if (IsChannelEnabled(ChannelName.Line.Value))
{
AddChannel(services);
}
- if (IsChannelEnabled("lark"))
+ if (IsChannelEnabled(ChannelName.Lark.Value))
{
AddChannel(services);
}
- if (IsChannelEnabled("wecom"))
+ if (IsChannelEnabled(ChannelName.WeCom.Value))
{
AddChannel(services);
}
diff --git a/src/clawsharp/Cli/Knowledge/KnowledgeIngestCommand.cs b/src/clawsharp/Cli/Knowledge/KnowledgeIngestCommand.cs
index 82bcfb18..dfb208fb 100644
--- a/src/clawsharp/Cli/Knowledge/KnowledgeIngestCommand.cs
+++ b/src/clawsharp/Cli/Knowledge/KnowledgeIngestCommand.cs
@@ -47,14 +47,14 @@ public override async Task ExecuteAsync(
AnsiConsole.MarkupLine($"[grey]Path:[/] {Markup.Escape(sourceConfig.Path ?? sourceConfig.Url ?? "(none)")}");
AnsiConsole.WriteLine();
- await using var sp = GatewayHost.BuildKnowledgeServiceProvider(config);
+ await using var sp = await GatewayHost.BuildKnowledgeServiceProviderAsync(config).ConfigureAwait(false);
var pipeline = sp.GetRequiredService();
var store = sp.GetRequiredService();
// Find or create the source entity
var normalizedUri = sourceConfig.Path ?? sourceConfig.Url ?? sourceConfig.Name;
- var sources = await store.ListSourcesAsync(cancellationToken);
+ var sources = await store.ListSourcesAsync(cancellationToken).ConfigureAwait(false);
var existingSource = sources.FirstOrDefault(
s => string.Equals(s.SourceUri, normalizedUri, StringComparison.Ordinal));
@@ -74,7 +74,7 @@ public override async Task ExecuteAsync(
try
{
- await pipeline.IngestSourceAsync(sourceConfig, sourceId, progress, cancellationToken, trigger: "cli");
+ await pipeline.IngestSourceAsync(sourceConfig, sourceId, progress, cancellationToken, trigger: "cli").ConfigureAwait(false);
return 0;
}
catch (Exception ex) when (ex is not OperationCanceledException)
@@ -113,11 +113,17 @@ internal static KnowledgeSourceConfig ResolveSourceConfig(AppConfig config, stri
};
}
+ var fullPath = Path.GetFullPath(source);
+ if (!File.Exists(fullPath) && !Directory.Exists(fullPath))
+ {
+ throw new FileNotFoundException($"Path not found: {fullPath}");
+ }
+
return new KnowledgeSourceConfig
{
Name = Path.GetFileName(source.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)),
Type = "local",
- Path = Path.GetFullPath(source),
+ Path = fullPath,
};
}
diff --git a/src/clawsharp/Cli/Knowledge/KnowledgeStatusCommand.cs b/src/clawsharp/Cli/Knowledge/KnowledgeStatusCommand.cs
index 1f530ac4..59ebabad 100644
--- a/src/clawsharp/Cli/Knowledge/KnowledgeStatusCommand.cs
+++ b/src/clawsharp/Cli/Knowledge/KnowledgeStatusCommand.cs
@@ -28,10 +28,10 @@ public override async Task ExecuteAsync(CommandContext context, Cancellatio
return 1;
}
- await using var sp = GatewayHost.BuildKnowledgeServiceProvider(config);
+ await using var sp = await GatewayHost.BuildKnowledgeServiceProviderAsync(config).ConfigureAwait(false);
var store = sp.GetRequiredService();
- var sources = await store.ListSourcesAsync(cancellationToken);
+ var sources = await store.ListSourcesAsync(cancellationToken).ConfigureAwait(false);
if (sources.Count == 0)
{
diff --git a/src/clawsharp/Cli/Memory/MemoryClearCommand.cs b/src/clawsharp/Cli/Memory/MemoryClearCommand.cs
index 3a7c1c79..9775f2cb 100644
--- a/src/clawsharp/Cli/Memory/MemoryClearCommand.cs
+++ b/src/clawsharp/Cli/Memory/MemoryClearCommand.cs
@@ -27,7 +27,7 @@ public override async Task ExecuteAsync(CommandContext context, Cancellatio
}
var memory = MemoryFactory.Create(config);
- await memory.ClearAsync(cancellationToken);
+ await memory.ClearAsync(cancellationToken).ConfigureAwait(false);
AnsiConsole.MarkupLine("[green]Memory cleared successfully.[/]");
return 0;
diff --git a/src/clawsharp/Cli/Memory/MemoryExportCommand.cs b/src/clawsharp/Cli/Memory/MemoryExportCommand.cs
index 8895399e..f1d5ae29 100644
--- a/src/clawsharp/Cli/Memory/MemoryExportCommand.cs
+++ b/src/clawsharp/Cli/Memory/MemoryExportCommand.cs
@@ -30,7 +30,7 @@ public override async Task ExecuteAsync(CommandContext context, Settings se
{
var config = ClawsharpConfiguration.GetAppConfig();
var memory = MemoryFactory.Create(config);
- var facts = await memory.ListFactsAsync(cancellationToken);
+ var facts = await memory.ListFactsAsync(cancellationToken).ConfigureAwait(false);
if (facts.Count == 0)
{
@@ -47,7 +47,7 @@ public override async Task ExecuteAsync(CommandContext context, Settings se
var list = new List(facts);
var json = JsonSerializer.Serialize(list, ConfigJsonContext.Default.ListFact);
- await File.WriteAllTextAsync(outputPath, json, cancellationToken);
+ await File.WriteAllTextAsync(outputPath, json, cancellationToken).ConfigureAwait(false);
AnsiConsole.MarkupLine($"[green]Exported {facts.Count} fact(s) to[/] {Markup.Escape(outputPath)}");
return 0;
diff --git a/src/clawsharp/Cli/Memory/MemoryListCommand.cs b/src/clawsharp/Cli/Memory/MemoryListCommand.cs
index 690fb8d8..573aa93d 100644
--- a/src/clawsharp/Cli/Memory/MemoryListCommand.cs
+++ b/src/clawsharp/Cli/Memory/MemoryListCommand.cs
@@ -18,7 +18,7 @@ public override async Task ExecuteAsync(CommandContext context, Cancellatio
{
var config = ClawsharpConfiguration.GetAppConfig();
var memory = MemoryFactory.Create(config);
- var facts = await memory.ListFactsAsync(cancellationToken);
+ var facts = await memory.ListFactsAsync(cancellationToken).ConfigureAwait(false);
if (facts.Count == 0)
{
diff --git a/src/clawsharp/Cli/Memory/MemorySearchCommand.cs b/src/clawsharp/Cli/Memory/MemorySearchCommand.cs
index bce6cec4..7ccf7a64 100644
--- a/src/clawsharp/Cli/Memory/MemorySearchCommand.cs
+++ b/src/clawsharp/Cli/Memory/MemorySearchCommand.cs
@@ -33,7 +33,7 @@ public override async Task ExecuteAsync(CommandContext context, Settings se
{
var config = ClawsharpConfiguration.GetAppConfig();
var memory = MemoryFactory.Create(config);
- var results = await memory.SearchAsync(settings.Query, settings.Limit, cancellationToken);
+ var results = await memory.SearchAsync(settings.Query, settings.Limit, cancellationToken).ConfigureAwait(false);
if (results.Count == 0)
{
diff --git a/src/clawsharp/Cli/Migrate/MigrateCommand.cs b/src/clawsharp/Cli/Migrate/MigrateCommand.cs
index 3f6be5ea..fa290c46 100644
--- a/src/clawsharp/Cli/Migrate/MigrateCommand.cs
+++ b/src/clawsharp/Cli/Migrate/MigrateCommand.cs
@@ -48,9 +48,9 @@ public override async Task ExecuteAsync(CommandContext ctx, Settings settin
return settings.Source.ToLowerInvariant() switch
{
- "picoclaw" => await MigratePicoClawAsync(settings, sourceConfig, destConfig, cancellation),
- "zeroclaw" => await MigrateZeroClawAsync(settings, sourceConfig, destConfig, cancellation),
- _ => await MigrateOpenClawAsync(settings, sourceConfig, destConfig, cancellation),
+ "picoclaw" => await MigratePicoClawAsync(settings, sourceConfig, destConfig, cancellation).ConfigureAwait(false),
+ "zeroclaw" => await MigrateZeroClawAsync(settings, sourceConfig, destConfig, cancellation).ConfigureAwait(false),
+ _ => await MigrateOpenClawAsync(settings, sourceConfig, destConfig, cancellation).ConfigureAwait(false),
};
}
@@ -70,7 +70,7 @@ private static async Task MigrateOpenClawAsync(
AnsiConsole.MarkupLine($"[bold]Writing to:[/] {destConfig}");
AnsiConsole.WriteLine();
- var sourceText = await File.ReadAllTextAsync(sourceConfig, ct);
+ var sourceText = await File.ReadAllTextAsync(sourceConfig, ct).ConfigureAwait(false);
var source = JsonNode.Parse(sourceText);
if (source is null)
{
@@ -81,7 +81,7 @@ private static async Task MigrateOpenClawAsync(
JsonNode dest;
if (File.Exists(destConfig))
{
- var existing = await File.ReadAllTextAsync(destConfig, ct);
+ var existing = await File.ReadAllTextAsync(destConfig, ct).ConfigureAwait(false);
dest = JsonNode.Parse(existing) ?? new JsonObject();
}
else
@@ -202,7 +202,7 @@ private static async Task MigrateOpenClawAsync(
warnings.Add("'hooks': Plugin hooks not available in clawsharp");
}
- return await WriteDestConfig(settings, dest, destConfig, migrated, warnings, ct);
+ return await WriteDestConfig(settings, dest, destConfig, migrated, warnings, ct).ConfigureAwait(false);
}
// ── picoclaw ──────────────────────────────────────────────────────────────
@@ -221,7 +221,7 @@ private static async Task MigratePicoClawAsync(
AnsiConsole.MarkupLine($"[bold]Writing to:[/] {destConfig}");
AnsiConsole.WriteLine();
- var sourceText = await File.ReadAllTextAsync(sourceConfig, ct);
+ var sourceText = await File.ReadAllTextAsync(sourceConfig, ct).ConfigureAwait(false);
var source = JsonNode.Parse(sourceText);
if (source is null)
{
@@ -232,7 +232,7 @@ private static async Task MigratePicoClawAsync(
JsonNode dest;
if (File.Exists(destConfig))
{
- var existing = await File.ReadAllTextAsync(destConfig, ct);
+ var existing = await File.ReadAllTextAsync(destConfig, ct).ConfigureAwait(false);
dest = JsonNode.Parse(existing) ?? new JsonObject();
}
else
@@ -366,7 +366,7 @@ private static async Task MigratePicoClawAsync(
warnings.Add("'session': picoclaw session config not applicable; clawsharp manages sessions automatically");
}
- return await WriteDestConfig(settings, dest, destConfig, migrated, warnings, ct);
+ return await WriteDestConfig(settings, dest, destConfig, migrated, warnings, ct).ConfigureAwait(false);
}
// ── zeroclaw ──────────────────────────────────────────────────────────────
@@ -385,13 +385,13 @@ private static async Task MigrateZeroClawAsync(
AnsiConsole.MarkupLine($"[bold]Writing to:[/] {destConfig}");
AnsiConsole.WriteLine();
- var tomlText = await File.ReadAllTextAsync(sourceConfig, ct);
+ var tomlText = await File.ReadAllTextAsync(sourceConfig, ct).ConfigureAwait(false);
var toml = ParseToml(tomlText);
JsonNode dest;
if (File.Exists(destConfig))
{
- var existing = await File.ReadAllTextAsync(destConfig, ct);
+ var existing = await File.ReadAllTextAsync(destConfig, ct).ConfigureAwait(false);
dest = JsonNode.Parse(existing) ?? new JsonObject();
}
else
@@ -491,7 +491,7 @@ private static async Task MigrateZeroClawAsync(
migrated.Add("tools.brave.apiKey");
}
- return await WriteDestConfig(settings, dest, destConfig, migrated, warnings, ct);
+ return await WriteDestConfig(settings, dest, destConfig, migrated, warnings, ct).ConfigureAwait(false);
}
// ── TOML parser ───────────────────────────────────────────────────────────
@@ -633,7 +633,7 @@ private static async Task WriteDestConfig(
Directory.CreateDirectory(Path.GetDirectoryName(destConfig)!);
var destText = dest.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
- await File.WriteAllTextAsync(destConfig, destText, ct);
+ await File.WriteAllTextAsync(destConfig, destText, ct).ConfigureAwait(false);
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine($"[bold green]Config written to {destConfig}[/]");
AnsiConsole.MarkupLine("Run [bold]clawsharp config validate[/] to check for issues.");
diff --git a/src/clawsharp/Cli/Models/ModelsJsonContext.cs b/src/clawsharp/Cli/Models/ModelsJsonContext.cs
index 0d20b861..a42466bd 100644
--- a/src/clawsharp/Cli/Models/ModelsJsonContext.cs
+++ b/src/clawsharp/Cli/Models/ModelsJsonContext.cs
@@ -5,4 +5,5 @@ namespace Clawsharp.Cli.Models;
/// Source-generated JSON context for model list API responses.
[JsonSerializable(typeof(OpenAiModelsResponse))]
[JsonSerializable(typeof(GeminiModelsResponse))]
+[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
internal sealed partial class ModelsJsonContext : JsonSerializerContext;
\ No newline at end of file
diff --git a/src/clawsharp/Cli/Models/ModelsListCommand.cs b/src/clawsharp/Cli/Models/ModelsListCommand.cs
index d31ce6ff..97d0c0d7 100644
--- a/src/clawsharp/Cli/Models/ModelsListCommand.cs
+++ b/src/clawsharp/Cli/Models/ModelsListCommand.cs
@@ -61,12 +61,12 @@ public override async Task ExecuteAsync(CommandContext context, Cancellatio
if (providerType == LlmProviderType.Gemini)
{
- await FetchGeminiModelsAsync(http, name, providerCfg, cancellationToken);
+ await FetchGeminiModelsAsync(http, name, providerCfg, cancellationToken).ConfigureAwait(false);
continue;
}
// All remaining types are OpenAI-compatible
- await FetchOpenAiModelsAsync(http, name, providerCfg, providerType, cancellationToken);
+ await FetchOpenAiModelsAsync(http, name, providerCfg, providerType, cancellationToken).ConfigureAwait(false);
}
return 0;
@@ -103,12 +103,12 @@ private static async Task FetchGeminiModelsAsync(
try
{
- using var response = await http.GetAsync(url, ct);
+ using var response = await http.GetAsync(url, ct).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
- await using var stream = await response.Content.ReadAsStreamAsync(ct);
+ await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
var result = await JsonSerializer.DeserializeAsync(
- stream, ModelsJsonContext.Default.GeminiModelsResponse, ct);
+ stream, ModelsJsonContext.Default.GeminiModelsResponse, ct).ConfigureAwait(false);
var models = result?.Models;
if (models is null || models.Count == 0)
@@ -173,12 +173,12 @@ private static async Task FetchOpenAiModelsAsync(
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", providerCfg.ApiKey);
}
- using var response = await http.SendAsync(request, ct);
+ using var response = await http.SendAsync(request, ct).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
- await using var stream = await response.Content.ReadAsStreamAsync(ct);
+ await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
var result = await JsonSerializer.DeserializeAsync(
- stream, ModelsJsonContext.Default.OpenAiModelsResponse, ct);
+ stream, ModelsJsonContext.Default.OpenAiModelsResponse, ct).ConfigureAwait(false);
var models = result?.Data;
if (models is null || models.Count == 0)
diff --git a/src/clawsharp/Cli/OnboardCommand.cs b/src/clawsharp/Cli/OnboardCommand.cs
index 7d8af660..a7f995a7 100644
--- a/src/clawsharp/Cli/OnboardCommand.cs
+++ b/src/clawsharp/Cli/OnboardCommand.cs
@@ -83,10 +83,10 @@ public override async Task ExecuteAsync(CommandContext context, Settings se
}
AnsiConsole.WriteLine();
- await SkillRegistry.InstallSkillsAsync(skillsToInstall, cancellationToken);
+ await SkillRegistry.InstallSkillsAsync(skillsToInstall, cancellationToken).ConfigureAwait(false);
await WriteConfigAndPrintSummary(
- providerType, model, apiKey, selectedChannels, channelCreds, skillsToInstall, cancellationToken);
+ providerType, model, apiKey, selectedChannels, channelCreds, skillsToInstall, cancellationToken).ConfigureAwait(false);
PrintOpenAccessWarnings(selectedChannels, channelCreds);
PrintChannelSecurityAdvisories(selectedChannels);
diff --git a/src/clawsharp/Cli/Pairing/PairingApproveCommand.cs b/src/clawsharp/Cli/Pairing/PairingApproveCommand.cs
index eeeb7b65..b633a278 100644
--- a/src/clawsharp/Cli/Pairing/PairingApproveCommand.cs
+++ b/src/clawsharp/Cli/Pairing/PairingApproveCommand.cs
@@ -27,7 +27,7 @@ public override async Task ExecuteAsync(CommandContext context, Settings se
var store = new PairingStore(
NullLogger.Instance);
- var approved = await store.ApproveAsync(settings.Code, cancellationToken);
+ var approved = await store.ApproveAsync(settings.Code, cancellationToken).ConfigureAwait(false);
if (approved is null)
{
AnsiConsole.MarkupLine("[red]Pairing code not found or expired.[/]");
@@ -37,7 +37,7 @@ public override async Task ExecuteAsync(CommandContext context, Settings se
// Add the sender to the dynamic approved-senders store (takes effect immediately on running gateways).
var approvedSenders = new ApprovedSendersStore(
NullLogger.Instance);
- await approvedSenders.AddAsync(approved.Channel, approved.SenderId, cancellationToken);
+ await approvedSenders.AddAsync(approved.Channel, approved.SenderId, cancellationToken).ConfigureAwait(false);
// Add the sender ID to channels.{channel}.allowFrom in ~/.clawsharp/config.json
var configPath = Path.Combine(ConfigLoader.ExpandHome("~/.clawsharp"), "config.json");
@@ -46,7 +46,7 @@ public override async Task ExecuteAsync(CommandContext context, Settings se
JsonNode? root = null;
if (File.Exists(configPath))
{
- var json = await File.ReadAllTextAsync(configPath, cancellationToken);
+ var json = await File.ReadAllTextAsync(configPath, cancellationToken).ConfigureAwait(false);
root = JsonNode.Parse(json);
}
@@ -94,7 +94,7 @@ public override async Task ExecuteAsync(CommandContext context, Settings se
var output = root.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
var tempPath = configPath + ".tmp";
- await File.WriteAllTextAsync(tempPath, output, cancellationToken);
+ await File.WriteAllTextAsync(tempPath, output, cancellationToken).ConfigureAwait(false);
File.Move(tempPath, configPath, overwrite: true);
AnsiConsole.MarkupLine(
diff --git a/src/clawsharp/Cli/Pairing/PairingListCommand.cs b/src/clawsharp/Cli/Pairing/PairingListCommand.cs
index 0b80dd16..5a12efc1 100644
--- a/src/clawsharp/Cli/Pairing/PairingListCommand.cs
+++ b/src/clawsharp/Cli/Pairing/PairingListCommand.cs
@@ -15,7 +15,7 @@ public override async Task ExecuteAsync(CommandContext context, Cancellatio
var store = new PairingStore(
Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance);
- var pending = await store.GetPendingAsync(cancellationToken);
+ var pending = await store.GetPendingAsync(cancellationToken).ConfigureAwait(false);
if (pending.Count == 0)
{
diff --git a/src/clawsharp/Cli/Policy/PolicyExplainCommand.cs b/src/clawsharp/Cli/Policy/PolicyExplainCommand.cs
index c0fbb991..64bde6e6 100644
--- a/src/clawsharp/Cli/Policy/PolicyExplainCommand.cs
+++ b/src/clawsharp/Cli/Policy/PolicyExplainCommand.cs
@@ -55,7 +55,7 @@ public override Task ExecuteAsync(CommandContext context, Settings settings
if (abacRules is { Count: > 0 })
{
// Use current time as frozen timestamp for CLI explain
- var ctx = new AbacContext(orgUser, Clawsharp.Core.Utilities.ChannelName.Cli, DateTimeOffset.UtcNow);
+ var ctx = new AbacContext(orgUser, Core.Utilities.ChannelName.Cli, DateTimeOffset.UtcNow);
abacDecision = evaluator.ApplyAbacRules(rbacDecision, abacRules, ctx);
}
diff --git a/src/clawsharp/Cli/Policy/PolicySimulateCommand.cs b/src/clawsharp/Cli/Policy/PolicySimulateCommand.cs
index 8a737713..7e3c2cca 100644
--- a/src/clawsharp/Cli/Policy/PolicySimulateCommand.cs
+++ b/src/clawsharp/Cli/Policy/PolicySimulateCommand.cs
@@ -64,7 +64,7 @@ public override Task ExecuteAsync(CommandContext context, Settings settings
var abacRules = config.Organization.Policies?.Rules;
if (abacRules is { Count: > 0 })
{
- var ctx = new AbacContext(orgUser, Clawsharp.Core.Utilities.ChannelName.Cli, DateTimeOffset.UtcNow);
+ var ctx = new AbacContext(orgUser, Core.Utilities.ChannelName.Cli, DateTimeOffset.UtcNow);
decision = evaluator.ApplyAbacRules(rbacDecision, abacRules, ctx);
}
else
diff --git a/src/clawsharp/Cli/Service/ServiceCommand.cs b/src/clawsharp/Cli/Service/ServiceCommand.cs
index 2fade910..aac328c7 100644
--- a/src/clawsharp/Cli/Service/ServiceCommand.cs
+++ b/src/clawsharp/Cli/Service/ServiceCommand.cs
@@ -21,7 +21,7 @@ public sealed class Settings : CommandSettings
}
public override async Task ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken)
- => await ServiceCommand.InstallAsync(settings.System, cancellationToken);
+ => await ServiceCommand.InstallAsync(settings.System, cancellationToken).ConfigureAwait(false);
}
/// Spectre command: clawsharp service uninstall [--system]
@@ -38,7 +38,7 @@ public sealed class Settings : CommandSettings
}
public override async Task ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken)
- => await ServiceCommand.UninstallAsync(settings.System, cancellationToken);
+ => await ServiceCommand.UninstallAsync(settings.System, cancellationToken).ConfigureAwait(false);
}
/// Spectre command: clawsharp service status
@@ -46,7 +46,7 @@ public override async Task ExecuteAsync(CommandContext context, Settings se
public sealed class ServiceStatusCommand : AsyncCommand
{
public override async Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
- => await ServiceCommand.StatusAsync(cancellationToken);
+ => await ServiceCommand.StatusAsync(cancellationToken).ConfigureAwait(false);
}
///
@@ -75,17 +75,17 @@ public static async Task InstallAsync(bool system, CancellationToken ct = d
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
- return await InstallSystemdAsync(binaryPath, system, ct);
+ return await InstallSystemdAsync(binaryPath, system, ct).ConfigureAwait(false);
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
- return await InstallLaunchdAsync(binaryPath, ct);
+ return await InstallLaunchdAsync(binaryPath, ct).ConfigureAwait(false);
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
- return await InstallWindowsServiceAsync(binaryPath, ct);
+ return await InstallWindowsServiceAsync(binaryPath, ct).ConfigureAwait(false);
}
AnsiConsole.MarkupLine("[red][[service]][/] Unsupported platform.");
@@ -98,17 +98,17 @@ public static async Task UninstallAsync(bool system, CancellationToken ct =
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
- return await UninstallSystemdAsync(system, ct);
+ return await UninstallSystemdAsync(system, ct).ConfigureAwait(false);
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
- return await UninstallLaunchdAsync(ct);
+ return await UninstallLaunchdAsync(ct).ConfigureAwait(false);
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
- return await UninstallWindowsServiceAsync(ct);
+ return await UninstallWindowsServiceAsync(ct).ConfigureAwait(false);
}
AnsiConsole.MarkupLine("[red][[service]][/] Unsupported platform.");
@@ -121,7 +121,7 @@ public static async Task StatusAsync(CancellationToken ct = default)
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
- return await RunAsync("systemctl", $"--user status {ServiceName}", ct);
+ return await RunAsync("systemctl", $"--user status {ServiceName}", ct).ConfigureAwait(false);
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
@@ -140,7 +140,7 @@ public static async Task StatusAsync(CancellationToken ct = default)
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
- return await RunAsync("sc.exe", $"query {ServiceName}", ct);
+ return await RunAsync("sc.exe", $"query {ServiceName}", ct).ConfigureAwait(false);
}
AnsiConsole.MarkupLine("[red][[service]][/] Unsupported platform.");
@@ -180,7 +180,7 @@ private static async Task InstallSystemdAsync(string binaryPath, bool syste
var configPath = GetConfigPath();
var unit = SystemdUnit(binaryPath, configPath, system);
- await File.WriteAllTextAsync(unitPath, unit, ct);
+ await File.WriteAllTextAsync(unitPath, unit, ct).ConfigureAwait(false);
AnsiConsole.MarkupLine($"[[service]] Unit file written: {Markup.Escape(unitPath)}");
if (await RunAsync("systemctl", $"{systemctlArgs} daemon-reload", ct) != 0)
@@ -213,8 +213,8 @@ private static async Task UninstallSystemdAsync(bool system, CancellationTo
var systemctlArgs = system ? "--system" : "--user";
- await RunAsync("systemctl", $"{systemctlArgs} stop {ServiceName}", ct);
- await RunAsync("systemctl", $"{systemctlArgs} disable {ServiceName}", ct);
+ await RunAsync("systemctl", $"{systemctlArgs} stop {ServiceName}", ct).ConfigureAwait(false);
+ await RunAsync("systemctl", $"{systemctlArgs} disable {ServiceName}", ct).ConfigureAwait(false);
string unitDir;
if (system)
@@ -234,7 +234,7 @@ private static async Task UninstallSystemdAsync(bool system, CancellationTo
AnsiConsole.MarkupLine($"[[service]] Deleted: {Markup.Escape(unitPath)}");
}
- await RunAsync("systemctl", $"{systemctlArgs} daemon-reload", ct);
+ await RunAsync("systemctl", $"{systemctlArgs} daemon-reload", ct).ConfigureAwait(false);
AnsiConsole.MarkupLine($"[green][[service]][/] {ServiceName} uninstalled.");
return 0;
}
@@ -286,7 +286,7 @@ private static async Task InstallLaunchdAsync(string binaryPath, Cancellati
var configPath = GetConfigPath();
var plist = LaunchdPlist(binaryPath, configPath);
- await File.WriteAllTextAsync(plistPath, plist, ct);
+ await File.WriteAllTextAsync(plistPath, plist, ct).ConfigureAwait(false);
AnsiConsole.MarkupLine($"[[service]] Plist written: {Markup.Escape(plistPath)}");
var label = LaunchdLabel();
@@ -309,7 +309,7 @@ private static async Task UninstallLaunchdAsync(CancellationToken ct)
return 0;
}
- await RunAsync("launchctl", $"unload -w {plistPath}", ct);
+ await RunAsync("launchctl", $"unload -w {plistPath}", ct).ConfigureAwait(false);
File.Delete(plistPath);
AnsiConsole.MarkupLine($"[[service]] Deleted: {Markup.Escape(plistPath)}");
return 0;
@@ -375,15 +375,15 @@ private static string LaunchdPlist(string binaryPath, string? configPath)
private static async Task InstallWindowsServiceAsync(string binaryPath, CancellationToken ct)
{
var code = await RunAsync("sc.exe",
- $"create {ServiceName} binPath= \"{binaryPath} gateway\" start= auto DisplayName= \"{ServiceDesc}\"", ct);
+ $"create {ServiceName} binPath= \"{binaryPath} gateway\" start= auto DisplayName= \"{ServiceDesc}\"", ct).ConfigureAwait(false);
if (code != 0)
{
return code;
}
- await RunAsync("sc.exe", $"description {ServiceName} \"{ServiceDesc}\"", ct);
+ await RunAsync("sc.exe", $"description {ServiceName} \"{ServiceDesc}\"", ct).ConfigureAwait(false);
- code = await RunAsync("sc.exe", $"start {ServiceName}", ct);
+ code = await RunAsync("sc.exe", $"start {ServiceName}", ct).ConfigureAwait(false);
if (code != 0)
{
return code;
@@ -396,8 +396,8 @@ private static async Task InstallWindowsServiceAsync(string binaryPath, Can
private static async Task UninstallWindowsServiceAsync(CancellationToken ct)
{
- await RunAsync("sc.exe", $"stop {ServiceName}", ct);
- var code = await RunAsync("sc.exe", $"delete {ServiceName}", ct);
+ await RunAsync("sc.exe", $"stop {ServiceName}", ct).ConfigureAwait(false);
+ var code = await RunAsync("sc.exe", $"delete {ServiceName}", ct).ConfigureAwait(false);
if (code == 0)
{
AnsiConsole.MarkupLine($"[green][[service]][/] {ServiceName} uninstalled.");
@@ -472,7 +472,7 @@ private static async Task RunAsync(string exe, string args, CancellationTok
return 1;
}
- await proc.WaitForExitAsync(ct);
+ await proc.WaitForExitAsync(ct).ConfigureAwait(false);
return proc.ExitCode;
}
catch (Exception ex)
diff --git a/src/clawsharp/Cli/Session/SessionCommand.cs b/src/clawsharp/Cli/Session/SessionCommand.cs
index f2f01682..60fefb12 100644
--- a/src/clawsharp/Cli/Session/SessionCommand.cs
+++ b/src/clawsharp/Cli/Session/SessionCommand.cs
@@ -36,7 +36,7 @@ public override async Task ExecuteAsync(CommandContext context, Cancellatio
try
{
await using var stream = File.OpenRead(fi.FullName);
- var session = await JsonSerializer.DeserializeAsync(stream, SessionJsonContext.Default.Session, cancellationToken);
+ var session = await JsonSerializer.DeserializeAsync(stream, SessionJsonContext.Default.Session, cancellationToken).ConfigureAwait(false);
if (session is null)
{
return (Name: Path.GetFileNameWithoutExtension(fi.Name), Messages: 0, In: 0L, Out: 0L, Ok: false);
diff --git a/src/clawsharp/Cli/SingleShotCommand.cs b/src/clawsharp/Cli/SingleShotCommand.cs
index de19f756..6f4e3909 100644
--- a/src/clawsharp/Cli/SingleShotCommand.cs
+++ b/src/clawsharp/Cli/SingleShotCommand.cs
@@ -42,7 +42,7 @@ public static async Task RunAsync(string message, CancellationToken ct = default
{
if (provider is IStreamingProvider streamingProvider)
{
- await foreach (var chunk in streamingProvider.StreamAsync(request, ct))
+ await foreach (var chunk in streamingProvider.StreamAsync(request, ct).ConfigureAwait(false))
{
if (chunk is TextDeltaChunk td)
{
@@ -54,7 +54,7 @@ public static async Task RunAsync(string message, CancellationToken ct = default
}
else
{
- var response = await provider.ChatAsync(request, ct);
+ var response = await provider.ChatAsync(request, ct).ConfigureAwait(false);
AnsiConsole.MarkupLine(Markup.Escape(response.Content ?? "(no response)"));
}
}
diff --git a/src/clawsharp/Cli/Skills/SkillRegistry.cs b/src/clawsharp/Cli/Skills/SkillRegistry.cs
index e7c05dd6..3cd3b2ae 100644
--- a/src/clawsharp/Cli/Skills/SkillRegistry.cs
+++ b/src/clawsharp/Cli/Skills/SkillRegistry.cs
@@ -127,13 +127,13 @@ public static async Task InstallSkillAsync(string skill, CancellationToken ct)
switch (entry.Source)
{
case SkillSource.BuiltIn:
- await WriteBuiltInSkillAsync(skill, destDir, ct);
+ await WriteBuiltInSkillAsync(skill, destDir, ct).ConfigureAwait(false);
break;
case SkillSource.GitClone:
- await GitCloneSkillAsync(skill, entry.CloneUrl!, destDir);
+ await GitCloneSkillAsync(skill, entry.CloneUrl!, destDir).ConfigureAwait(false);
break;
case SkillSource.GitHubApi:
- await GitHubApiDownloadAsync(skill, entry.GitHubRepo!, entry.GitHubPath!, destDir, ct);
+ await GitHubApiDownloadAsync(skill, entry.GitHubRepo!, entry.GitHubPath!, destDir, ct).ConfigureAwait(false);
break;
}
}
@@ -142,7 +142,7 @@ public static async Task InstallSkillsAsync(IReadOnlyList skills, Cancel
{
foreach (var skill in skills)
{
- await InstallSkillAsync(skill, ct);
+ await InstallSkillAsync(skill, ct).ConfigureAwait(false);
}
}
@@ -161,7 +161,7 @@ private static async Task WriteBuiltInSkillAsync(string skill, string destDir, C
return;
}
- await File.WriteAllTextAsync(Path.Combine(destDir, "SKILL.md"), content, ct);
+ await File.WriteAllTextAsync(Path.Combine(destDir, "SKILL.md"), content, ct).ConfigureAwait(false);
AnsiConsole.MarkupLine($" Installed {Markup.Escape(skill)} (built-in)");
}
@@ -187,7 +187,7 @@ private static async Task GitCloneSkillAsync(string skill, string repoUrl, strin
throw new InvalidOperationException("Failed to start git");
}
- await proc.WaitForExitAsync();
+ await proc.WaitForExitAsync().ConfigureAwait(false);
if (proc.ExitCode == 0)
{
AnsiConsole.MarkupLine("[green]done[/]");
@@ -211,7 +211,7 @@ private static async Task GitHubApiDownloadAsync(string skill, string repo, stri
try
{
Directory.CreateDirectory(destDir);
- await DownloadGitHubDirAsync(SharedHttpClient, repo, repoPath, destDir, ct);
+ await DownloadGitHubDirAsync(SharedHttpClient, repo, repoPath, destDir, ct).ConfigureAwait(false);
AnsiConsole.MarkupLine("[green]done[/]");
}
catch (Exception ex)
@@ -225,7 +225,7 @@ private static async Task GitHubApiDownloadAsync(string skill, string repo, stri
private static async Task DownloadGitHubDirAsync(HttpClient http, string repo, string path, string localDir, CancellationToken ct)
{
var url = $"https://api.github.com/repos/{repo}/contents/{path}";
- var response = await http.GetStringAsync(url, ct);
+ var response = await http.GetStringAsync(url, ct).ConfigureAwait(false);
using var doc = JsonDocument.Parse(response);
foreach (var entry in doc.RootElement.EnumerateArray())
@@ -243,7 +243,7 @@ private static async Task DownloadGitHubDirAsync(HttpClient http, string repo, s
}
var dlUrl = dlProp.GetString()!;
- var bytes = await http.GetByteArrayAsync(dlUrl, ct);
+ var bytes = await http.GetByteArrayAsync(dlUrl, ct).ConfigureAwait(false);
// Redact hardcoded demo API key that ships in supermemory SKILL.md
if (name.Equals("SKILL.md", StringComparison.OrdinalIgnoreCase))
{
@@ -252,12 +252,12 @@ private static async Task DownloadGitHubDirAsync(HttpClient http, string repo, s
bytes = Encoding.UTF8.GetBytes(text);
}
- await File.WriteAllBytesAsync(local, bytes, ct);
+ await File.WriteAllBytesAsync(local, bytes, ct).ConfigureAwait(false);
}
else if (type == "dir")
{
Directory.CreateDirectory(local);
- await DownloadGitHubDirAsync(http, repo, $"{path}/{name}", local, ct);
+ await DownloadGitHubDirAsync(http, repo, $"{path}/{name}", local, ct).ConfigureAwait(false);
}
}
}
diff --git a/src/clawsharp/Cli/Skills/SkillsInstallCommand.cs b/src/clawsharp/Cli/Skills/SkillsInstallCommand.cs
index 3026fbdd..e907ade7 100644
--- a/src/clawsharp/Cli/Skills/SkillsInstallCommand.cs
+++ b/src/clawsharp/Cli/Skills/SkillsInstallCommand.cs
@@ -23,7 +23,7 @@ public override async Task ExecuteAsync(CommandContext context, Settings se
return 1;
}
- await SkillRegistry.InstallSkillAsync(settings.Name, cancellationToken);
+ await SkillRegistry.InstallSkillAsync(settings.Name, cancellationToken).ConfigureAwait(false);
return 0;
}
}
\ No newline at end of file
diff --git a/src/clawsharp/Cli/StatusCommand.cs b/src/clawsharp/Cli/StatusCommand.cs
index 5f4790c9..42ba3cd3 100644
--- a/src/clawsharp/Cli/StatusCommand.cs
+++ b/src/clawsharp/Cli/StatusCommand.cs
@@ -82,7 +82,7 @@ public override async Task ExecuteAsync(CommandContext context, Cancellatio
AnsiConsole.WriteLine();
// Token totals (sum across all sessions)
- var (totalIn, totalOut, sessionCount) = await ScanSessionTokensAsync(cancellationToken);
+ var (totalIn, totalOut, sessionCount) = await ScanSessionTokensAsync(cancellationToken).ConfigureAwait(false);
AnsiConsole.MarkupLine($"[cyan]Sessions[/] : {sessionCount}");
if (totalIn > 0 || totalOut > 0)
{
@@ -111,7 +111,7 @@ public override async Task ExecuteAsync(CommandContext context, Cancellatio
try
{
await using var stream = File.OpenRead(file);
- var session = await JsonSerializer.DeserializeAsync(stream, SessionJsonContext.Default.Session, ct);
+ var session = await JsonSerializer.DeserializeAsync(stream, SessionJsonContext.Default.Session, ct).ConfigureAwait(false);
return session is null ? (0L, 0L, 0) : (session.TotalInputTokens, session.TotalOutputTokens, 1);
}
catch
diff --git a/src/clawsharp/Config/AppConfig.cs b/src/clawsharp/Config/AppConfig.cs
index f2e7284a..81dcd420 100644
--- a/src/clawsharp/Config/AppConfig.cs
+++ b/src/clawsharp/Config/AppConfig.cs
@@ -3,6 +3,7 @@
using Clawsharp.Config.Channels;
using Clawsharp.Config.Features;
using Clawsharp.Config.Memory;
+using Clawsharp.Config.Organization;
using Clawsharp.Config.Security;
using Clawsharp.A2a;
using Clawsharp.Knowledge.Config;
@@ -41,15 +42,39 @@ public sealed class AppConfig
/// MCP server configurations keyed by server name.
public Dictionary? McpServers { get; init; }
+ ///
+ /// MCP server mode configuration (exposing clawsharp tools to external MCP clients).
+ /// Null = disabled (zero overhead).
+ ///
+ public McpServerModeConfig? McpServer { get; init; }
+
/// Security configuration.
public SecurityConfig? Security { get; init; }
+ ///
+ /// Organization and multi-user identity/policy configuration.
+ /// Null = single-operator mode (v1.5.0 behavior).
+ ///
+ public OrganizationConfig? Organization { get; init; }
+
/// At-rest secrets encryption configuration.
public SecretsConfig? Secrets { get; init; }
/// Voice transcription configuration (Groq Whisper / OpenAI Whisper), shared by all channels.
public TranscriptionConfig? Transcription { get; init; }
+ ///
+ /// OpenTelemetry observability configuration (traces, metrics, logs).
+ /// Null = disabled (zero overhead).
+ ///
+ public TelemetryConfig? Telemetry { get; init; }
+
+ ///
+ /// Webhook / event subscription system configuration.
+ /// Null = disabled (zero overhead).
+ ///
+ public WebhookConfig? Webhooks { get; init; }
+
/// HTTP request settings (proxy) for outbound LLM provider calls.
public HttpRequestConfig? HttpRequest { get; init; }
diff --git a/src/clawsharp/Config/ClawsharpConfiguration.cs b/src/clawsharp/Config/ClawsharpConfiguration.cs
index 2270c36d..15ab63c3 100644
--- a/src/clawsharp/Config/ClawsharpConfiguration.cs
+++ b/src/clawsharp/Config/ClawsharpConfiguration.cs
@@ -167,6 +167,14 @@ string Resolve(string? value)
{
provider.ApiKey = Resolve(provider.ApiKey);
provider.AwsSecretAccessKey = Resolve(provider.AwsSecretAccessKey);
+
+ if (provider.ApiKeys is { } keys)
+ {
+ for (var i = 0; i < keys.Count; i++)
+ {
+ keys[i] = Resolve(keys[i]);
+ }
+ }
}
if (config.Transcription is { } t)
diff --git a/src/clawsharp/Config/ConfigKeyValidator.cs b/src/clawsharp/Config/ConfigKeyValidator.cs
index 00a7f5ae..5a2abb00 100644
--- a/src/clawsharp/Config/ConfigKeyValidator.cs
+++ b/src/clawsharp/Config/ConfigKeyValidator.cs
@@ -65,6 +65,9 @@ internal static class ConfigKeyValidator
"agents.defaults.thinking.reasoningEffort",
"agents.defaults.thinking.geminiBudgetTokens",
+ // agents.defaults.spawn
+ "agents.defaults.spawnTimeout",
+
// agents.defaults.modelRouting
"agents.defaults.modelRouting.enabled",
"agents.defaults.modelRouting.simpleModel",
@@ -102,6 +105,7 @@ internal static class ConfigKeyValidator
"memory.factExtraction.minChars",
// tools — core
+ "tools.shellEnabled",
"tools.workspace",
"tools.requireShellApproval",
"tools.enableShellDenyPatterns",
diff --git a/src/clawsharp/Config/ConfigValidator.cs b/src/clawsharp/Config/ConfigValidator.cs
index 849b7757..469c3b77 100644
--- a/src/clawsharp/Config/ConfigValidator.cs
+++ b/src/clawsharp/Config/ConfigValidator.cs
@@ -4,6 +4,7 @@
using Clawsharp.Config.Memory;
using Clawsharp.Config.Organization;
using Clawsharp.Config.Security;
+using Clawsharp.Knowledge.Config;
using Clawsharp.Security;
namespace Clawsharp.Config;
@@ -247,6 +248,31 @@ public static List Validate(AppConfig config)
}
}
+ // ── Knowledge ────────────────────────────────────────────────────────
+ if (config.Knowledge is { Enabled: true })
+ {
+ if (config.Memory.Embedding is null
+ || string.IsNullOrWhiteSpace(config.Memory.Embedding.Provider))
+ {
+ errors.Add("knowledge is enabled but memory.embedding is not configured. " +
+ "Set memory.embedding.provider to 'openai' or 'ollama'.");
+ }
+
+ ValidateChunkingConfig(errors, config.Knowledge.Chunking, "knowledge.chunking");
+
+ if (config.Knowledge.Sources is { Count: > 0 })
+ {
+ for (var i = 0; i < config.Knowledge.Sources.Count; i++)
+ {
+ var source = config.Knowledge.Sources[i];
+ if (source.Chunking is not null)
+ {
+ ValidateChunkingConfig(errors, source.Chunking, $"knowledge.sources[{i}].chunking");
+ }
+ }
+ }
+ }
+
// ── Egress policy ────────────────────────────────────────────────────
if (config.Security?.Egress is { } egress)
{
@@ -434,6 +460,14 @@ private static void ValidateAbacRules(List errors, List rules)
errors.Add($"{prefix}: duplicate ruleId '{effectiveId}'.");
}
+ // Deny rules must specify when.tool (otherwise they silently match nothing)
+ if (rule.When is not null
+ && string.Equals(rule.Effect, AbacRule.Effects.Deny, StringComparison.Ordinal)
+ && rule.When.Tool is null)
+ {
+ errors.Add($"{prefix}: deny rules must specify when.tool (use '*' to deny all tools).");
+ }
+
// Validate timeWindow entries
if (rule.When?.TimeWindow is { ValueKind: System.Text.Json.JsonValueKind.Array } tw)
{
@@ -468,6 +502,24 @@ private static bool IsValidTimeWindow(string window)
TimeOnly.TryParse(endPart, System.Globalization.CultureInfo.InvariantCulture, out _);
}
+ ///
+ /// Validates chunking configuration: chunk size and overlap bounds.
+ ///
+ private static void ValidateChunkingConfig(List errors, ChunkingConfig? config, string prefix)
+ {
+ if (config is null) return;
+
+ if (config.ChunkSize < 64)
+ {
+ errors.Add($"{prefix}.chunkSize must be at least 64 (got {config.ChunkSize}).");
+ }
+
+ if (config.Overlap < 0.0 || config.Overlap >= 1.0)
+ {
+ errors.Add($"{prefix}.overlap must be in [0.0, 1.0) (got {config.Overlap}).");
+ }
+ }
+
///
/// Validates the telemetry configuration block: endpoint URI, protocol, sampling range, and log level.
///
@@ -522,6 +574,13 @@ private static void ValidateMcpServerMode(
if (string.IsNullOrWhiteSpace(keyEntry.User))
errors.Add($"mcpServer.apiKeys.{keyId}: 'user' must not be empty.");
+ // Validate secret minimum length when explicitly set
+ if (keyEntry.Secret is not null && keyEntry.Secret.Length < 32)
+ {
+ errors.Add($"mcpServer.apiKeys.{keyId}: 'secret' must be at least 32 characters " +
+ $"(got {keyEntry.Secret.Length}). Use: openssl rand -hex 32");
+ }
+
// Validate that referenced user exists in org config (if org is configured)
if (config.Organization is not null &&
!config.Organization.Users.ContainsKey(keyEntry.User))
diff --git a/src/clawsharp/Config/DotEnvConfigurationSource.cs b/src/clawsharp/Config/DotEnvConfigurationSource.cs
index d45ebb8f..5be66680 100644
--- a/src/clawsharp/Config/DotEnvConfigurationSource.cs
+++ b/src/clawsharp/Config/DotEnvConfigurationSource.cs
@@ -38,7 +38,10 @@ public override void Load()
var key = trimmed[..eq].Trim();
var value = trimmed[(eq + 1)..].Trim();
- // Strip optional surrounding quotes (double or single)
+ // Strip optional surrounding quotes (double or single).
+ // Escape sequences (\n, \", etc.) within quoted values are NOT unescaped;
+ // this is intentionally simpler than dotenv/godotenv. Use single-quoted
+ // values or avoid escape sequences if this is a concern.
if (value.Length >= 2 && value[0] == '"' && value[^1] == '"')
{
value = value[1..^1];
diff --git a/src/clawsharp/Config/Features/McpServerModeConfig.cs b/src/clawsharp/Config/Features/McpServerModeConfig.cs
index 009b671f..de423319 100644
--- a/src/clawsharp/Config/Features/McpServerModeConfig.cs
+++ b/src/clawsharp/Config/Features/McpServerModeConfig.cs
@@ -17,10 +17,13 @@ public sealed class McpServerModeConfig
public string[]? AllowedOrigins { get; init; }
///
- /// API keys for Bearer token authentication. Key = key identifier, Value = key config.
+ /// API keys for Bearer token authentication. Key = the bearer token itself, Value = key config.
/// When null or empty in single-operator mode, auth is not required.
+ /// Dictionary keys cannot use enc2: encryption or op:// references because DecryptSecrets
+ /// can only mutate property values, not dictionary keys. Protect config.json with chmod 600
+ /// and CLAWSHARP_SECRET_KEY for at-rest protection of the entire file.
///
- public Dictionary? ApiKeys { get; init; }
+ public IReadOnlyDictionary? ApiKeys { get; init; }
}
///
@@ -33,4 +36,13 @@ public sealed class McpApiKeyEntry
/// Optional description for operator reference.
public string? Description { get; init; }
+
+ ///
+ /// The bearer token secret for this key entry. When set, the bearer token is this value
+ /// rather than the dictionary key (keyId). This separates the human-readable identifier
+ /// from the credential, preventing keyId from leaking via logs, OTel spans, and cost records.
+ /// When null, the dictionary key is used as the bearer secret for backward compatibility
+ /// (deprecated — a warning is logged at startup).
+ ///
+ public string? Secret { get; init; }
}
diff --git a/src/clawsharp/Config/JsonContext.cs b/src/clawsharp/Config/JsonContext.cs
index 34113d51..35f79c8c 100644
--- a/src/clawsharp/Config/JsonContext.cs
+++ b/src/clawsharp/Config/JsonContext.cs
@@ -5,6 +5,7 @@
using Clawsharp.Config.Channels;
using Clawsharp.Config.Features;
using Clawsharp.Config.Memory;
+using Clawsharp.Config.Organization;
using Clawsharp.Config.Search;
using Clawsharp.Config.Security;
using Clawsharp.A2a;
@@ -59,6 +60,24 @@ namespace Clawsharp.Config;
JsonSerializable(typeof(LandlockConfig)),
JsonSerializable(typeof(EgressConfig)), JsonSerializable(typeof(EgressRule)),
JsonSerializable(typeof(List)), JsonSerializable(typeof(EgressMode)),
+ // Organization
+ JsonSerializable(typeof(OrganizationConfig)), JsonSerializable(typeof(OrgUserConfig)),
+ JsonSerializable(typeof(PoliciesConfig)), JsonSerializable(typeof(RolePolicy)),
+ JsonSerializable(typeof(DepartmentConfig)), JsonSerializable(typeof(PolicyDefaults)),
+ JsonSerializable(typeof(AdminNotifyConfig)), JsonSerializable(typeof(BudgetLimits)),
+ JsonSerializable(typeof(AbacRule)), JsonSerializable(typeof(AbacCondition)),
+ JsonSerializable(typeof(IdpConfig)), JsonSerializable(typeof(ClaimsConfig)),
+ JsonSerializable(typeof(Dictionary)),
+ JsonSerializable(typeof(Dictionary)),
+ JsonSerializable(typeof(Dictionary)),
+ JsonSerializable(typeof(List)),
+ // Telemetry
+ JsonSerializable(typeof(TelemetryConfig)),
+ // MCP server mode
+ JsonSerializable(typeof(McpServerModeConfig)), JsonSerializable(typeof(McpApiKeyEntry)),
+ // Webhooks
+ JsonSerializable(typeof(WebhookConfig)), JsonSerializable(typeof(WebhookEndpointConfig)),
+ JsonSerializable(typeof(Dictionary)),
// Intellenum config types
JsonSerializable(typeof(DmPolicy)), JsonSerializable(typeof(GroupPolicy)),
JsonSerializable(typeof(ReasoningEffort)), JsonSerializable(typeof(PromptGuardMode)),
diff --git a/src/clawsharp/Config/Organization/ConfigMutator.cs b/src/clawsharp/Config/Organization/ConfigMutator.cs
index d739cc7f..01da169d 100644
--- a/src/clawsharp/Config/Organization/ConfigMutator.cs
+++ b/src/clawsharp/Config/Organization/ConfigMutator.cs
@@ -1,5 +1,6 @@
using System.Text.Json;
using System.Text.Json.Nodes;
+using Microsoft.Extensions.Logging;
namespace Clawsharp.Config.Organization;
@@ -7,12 +8,19 @@ namespace Clawsharp.Config.Organization;
/// Provides atomic read-modify-write operations on ~/.clawsharp/config.json.
/// Serializes concurrent mutations with a per Pitfall #1.
///
-public static class ConfigMutator
+public static partial class ConfigMutator
{
private static readonly SemaphoreSlim Lock = new(1, 1);
private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true };
+ private static ILogger? _logger;
+
+ ///
+ /// Sets the logger for ConfigMutator. Called once during DI setup.
+ ///
+ internal static void SetLogger(ILogger logger) => _logger = logger;
+
///
/// Reads config.json, applies to the parsed ,
/// and writes the result atomically via temp file + .
@@ -44,7 +52,14 @@ internal static async Task MutateConfigAsync(string configPath, Action
if (File.Exists(configPath))
{
var json = await File.ReadAllTextAsync(configPath, ct).ConfigureAwait(false);
- root = JsonNode.Parse(json);
+ if (!string.IsNullOrWhiteSpace(json))
+ {
+ root = JsonNode.Parse(json);
+ }
+ else
+ {
+ LogEmptyConfigFile(_logger, configPath);
+ }
}
root ??= new JsonObject();
@@ -61,4 +76,11 @@ internal static async Task MutateConfigAsync(string configPath, Action
Lock.Release();
}
}
+
+ [LoggerMessage(EventId = 1, Level = LogLevel.Warning,
+ Message = "Config file '{ConfigPath}' exists but is empty; treating as missing")]
+ private static partial void LogEmptyConfigFile(ILogger? logger, string configPath);
}
+
+/// Marker type for in .
+public sealed class ConfigMutatorLogger;
diff --git a/src/clawsharp/Config/Organization/PolicyDefaults.cs b/src/clawsharp/Config/Organization/PolicyDefaults.cs
index 4152f3f2..4a5ab458 100644
--- a/src/clawsharp/Config/Organization/PolicyDefaults.cs
+++ b/src/clawsharp/Config/Organization/PolicyDefaults.cs
@@ -6,11 +6,16 @@ namespace Clawsharp.Config.Organization;
///
public sealed class PolicyDefaults
{
+ ///
+ /// The fallback role name used when no explicit default is configured.
+ ///
+ public const string DefaultRoleName = "user";
+
///
/// The role assigned to unknown senders when is false.
/// Must reference a key in .
///
- public string DefaultRole { get; init; } = "user";
+ public string DefaultRole { get; init; } = DefaultRoleName;
///
/// When true, unknown senders are denied with an explanatory message.
diff --git a/src/clawsharp/Config/Security/SecurityConfig.cs b/src/clawsharp/Config/Security/SecurityConfig.cs
index 70be53b2..75da82c5 100644
--- a/src/clawsharp/Config/Security/SecurityConfig.cs
+++ b/src/clawsharp/Config/Security/SecurityConfig.cs
@@ -88,9 +88,10 @@ public sealed class LeakDetectorConfig
{
///
/// Detection sensitivity (0.0–1.0, default 0.7).
- /// At 0.0: only structural patterns (API keys, AWS, JWTs, private keys, DB URLs).
+ /// At 0.0: structural patterns only (API keys, AWS credentials, JWTs, private keys, DB URLs).
/// Above 0.5: also generic secrets (password=, token=) and high-entropy tokens.
- /// Set to 0 to disable leak detection entirely.
+ /// Structural-pattern detection cannot be disabled — this is intentional.
+ /// To minimize scan impact, set to 0.0.
///
[System.ComponentModel.DataAnnotations.Range(0.0, 1.0)]
public double Sensitivity { get; init; } = 0.7;
diff --git a/src/clawsharp/Core/AgentStepExecutor.cs b/src/clawsharp/Core/AgentStepExecutor.cs
index 1255bf71..fbda8377 100644
--- a/src/clawsharp/Core/AgentStepExecutor.cs
+++ b/src/clawsharp/Core/AgentStepExecutor.cs
@@ -80,7 +80,7 @@ public async Task ExecuteAsync(
ChatResponse response;
try
{
- response = await provider.ChatAsync(chatRequest, ct);
+ response = await provider.ChatAsync(chatRequest, ct).ConfigureAwait(false);
}
catch (Exception ex)
{
@@ -101,16 +101,29 @@ public async Task ExecuteAsync(
messages.Add(new ChatMessage(MessageRole.Assistant, response.Content,
ToolCalls: response.ToolCalls));
- foreach (var tc in response.ToolCalls)
+ if (response.ToolCalls.Count == 1)
{
+ var tc = response.ToolCalls[0];
toolCallCount++;
-
- // Invoke the pre-execution callback if provided (e.g. to set RBAC context)
request.BeforeToolExecution?.Invoke(tc);
-
- var result = await tools.ExecuteAsync(tc.Name, tc.ArgumentsJson, ct);
+ var result = await tools.ExecuteAsync(tc.Name, tc.ArgumentsJson, ct).ConfigureAwait(false);
messages.Add(new ChatMessage(MessageRole.Tool, result, ToolCallId: tc.Id, Name: tc.Name));
}
+ else
+ {
+ var toolCalls = response.ToolCalls;
+ toolCallCount += toolCalls.Count;
+ foreach (var tc in toolCalls)
+ request.BeforeToolExecution?.Invoke(tc);
+
+ var tasks = new Task[toolCalls.Count];
+ for (var i = 0; i < toolCalls.Count; i++)
+ tasks[i] = tools.ExecuteAsync(toolCalls[i].Name, toolCalls[i].ArgumentsJson, ct);
+
+ var results = await Task.WhenAll(tasks).ConfigureAwait(false);
+ for (var i = 0; i < toolCalls.Count; i++)
+ messages.Add(new ChatMessage(MessageRole.Tool, results[i], ToolCallId: toolCalls[i].Id, Name: toolCalls[i].Name));
+ }
chatRequest = chatRequest with { Messages = messages };
continue;
@@ -151,6 +164,18 @@ public async IAsyncEnumerable StreamAsync(
IToolRegistry tools,
[EnumeratorCancellation] CancellationToken ct = default)
{
+ // Mirror ExecuteAsync: capture parent context for ActivityLink, then create a new trace root.
+ var parentSpawnContext = Activity.Current?.Context;
+ Activity.Current = null;
+ var links = parentSpawnContext.HasValue
+ ? new[] { new ActivityLink(parentSpawnContext.Value) }
+ : null;
+ using var activity = ClawsharpActivitySources.Pipeline.StartActivity(
+ "agent.step",
+ ActivityKind.Internal,
+ parentContext: default(ActivityContext),
+ links: links);
+
var messages = new List
{
new(MessageRole.System, request.SystemPrompt),
@@ -185,7 +210,7 @@ public async IAsyncEnumerable StreamAsync(
// ── Streaming path ──────────────────────────────────────────
// Consume the stream into collected events + tool builders via a non-yielding helper.
// C# disallows yield inside try-catch, so we separate consumption from yielding.
- var consumeResult = await ConsumeStreamAsync(sp, chatRequest, ct);
+ var consumeResult = await ConsumeStreamAsync(sp, chatRequest, ct).ConfigureAwait(false);
if (consumeResult.Failed)
{
@@ -214,15 +239,36 @@ public async IAsyncEnumerable StreamAsync(
messages.Add(new ChatMessage(MessageRole.Assistant, assistantText,
ToolCalls: toolCalls));
- foreach (var tc in toolCalls)
+ if (toolCalls.Count == 1)
{
+ var tc = toolCalls[0];
yield return new StreamEvent.ToolStart(tc.Name);
request.BeforeToolExecution?.Invoke(tc);
- var result = await tools.ExecuteAsync(tc.Name, tc.ArgumentsJson, ct);
+ var result = await tools.ExecuteAsync(tc.Name, tc.ArgumentsJson, ct).ConfigureAwait(false);
yield return new StreamEvent.ToolResult(tc.Name, result);
messages.Add(new ChatMessage(MessageRole.Tool, result, ToolCallId: tc.Id,
Name: tc.Name));
}
+ else
+ {
+ foreach (var tc in toolCalls)
+ {
+ yield return new StreamEvent.ToolStart(tc.Name);
+ request.BeforeToolExecution?.Invoke(tc);
+ }
+
+ var tasks = new Task[toolCalls.Count];
+ for (var i = 0; i < toolCalls.Count; i++)
+ tasks[i] = tools.ExecuteAsync(toolCalls[i].Name, toolCalls[i].ArgumentsJson, ct);
+
+ var results = await Task.WhenAll(tasks).ConfigureAwait(false);
+ for (var i = 0; i < toolCalls.Count; i++)
+ {
+ yield return new StreamEvent.ToolResult(toolCalls[i].Name, results[i]);
+ messages.Add(new ChatMessage(MessageRole.Tool, results[i], ToolCallId: toolCalls[i].Id,
+ Name: toolCalls[i].Name));
+ }
+ }
chatRequest = chatRequest with { Messages = messages };
continue; // next iteration
@@ -249,7 +295,7 @@ public async IAsyncEnumerable StreamAsync(
else
{
// ── Fallback path: non-streaming provider ───────────────────
- var fallbackResult = await CallChatAsync(provider, chatRequest, ct);
+ var fallbackResult = await CallChatAsync(provider, chatRequest, ct).ConfigureAwait(false);
if (fallbackResult.Failed)
{
@@ -272,15 +318,36 @@ public async IAsyncEnumerable StreamAsync(
messages.Add(new ChatMessage(MessageRole.Assistant, response.Content,
ToolCalls: response.ToolCalls));
- foreach (var tc in response.ToolCalls)
+ if (response.ToolCalls.Count == 1)
{
+ var tc = response.ToolCalls[0];
yield return new StreamEvent.ToolStart(tc.Name);
request.BeforeToolExecution?.Invoke(tc);
- var result = await tools.ExecuteAsync(tc.Name, tc.ArgumentsJson, ct);
+ var result = await tools.ExecuteAsync(tc.Name, tc.ArgumentsJson, ct).ConfigureAwait(false);
yield return new StreamEvent.ToolResult(tc.Name, result);
messages.Add(new ChatMessage(MessageRole.Tool, result, ToolCallId: tc.Id,
Name: tc.Name));
}
+ else
+ {
+ foreach (var tc in response.ToolCalls)
+ {
+ yield return new StreamEvent.ToolStart(tc.Name);
+ request.BeforeToolExecution?.Invoke(tc);
+ }
+
+ var tasks = new Task[response.ToolCalls.Count];
+ for (var i = 0; i < response.ToolCalls.Count; i++)
+ tasks[i] = tools.ExecuteAsync(response.ToolCalls[i].Name, response.ToolCalls[i].ArgumentsJson, ct);
+
+ var results = await Task.WhenAll(tasks).ConfigureAwait(false);
+ for (var i = 0; i < response.ToolCalls.Count; i++)
+ {
+ yield return new StreamEvent.ToolResult(response.ToolCalls[i].Name, results[i]);
+ messages.Add(new ChatMessage(MessageRole.Tool, results[i], ToolCallId: response.ToolCalls[i].Id,
+ Name: response.ToolCalls[i].Name));
+ }
+ }
chatRequest = chatRequest with { Messages = messages };
continue; // next iteration
@@ -324,7 +391,7 @@ private async Task ConsumeStreamAsync(
try
{
- await foreach (var chunk in sp.StreamAsync(chatRequest, ct))
+ await foreach (var chunk in sp.StreamAsync(chatRequest, ct).ConfigureAwait(false))
{
switch (chunk)
{
@@ -381,7 +448,7 @@ private async Task CallChatAsync(
{
try
{
- var response = await provider.ChatAsync(chatRequest, ct);
+ var response = await provider.ChatAsync(chatRequest, ct).ConfigureAwait(false);
return new FallbackCallResult(response, Failed: false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
diff --git a/src/clawsharp/Core/Events/EventBus.cs b/src/clawsharp/Core/Events/EventBus.cs
index a94d1279..7a5d3895 100644
--- a/src/clawsharp/Core/Events/EventBus.cs
+++ b/src/clawsharp/Core/Events/EventBus.cs
@@ -1,5 +1,6 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
+using Remora.Discord.API.Objects;
namespace Clawsharp.Core.Events;
diff --git a/src/clawsharp/Core/Hosting/HttpHostService.cs b/src/clawsharp/Core/Hosting/HttpHostService.cs
index 3a17a3fb..449111e5 100644
--- a/src/clawsharp/Core/Hosting/HttpHostService.cs
+++ b/src/clawsharp/Core/Hosting/HttpHostService.cs
@@ -2,6 +2,7 @@
using Clawsharp.Core.Utilities;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -73,6 +74,7 @@ public async Task StartAsync(CancellationToken cancellationToken)
// Kestrel configuration — max request body size for WebSocket support.
builder.WebHost.ConfigureKestrel(options =>
{
+ options.AddServerHeader = false; // suppress "Server: Kestrel" disclosure
options.Limits.MaxRequestBodySize = 1 * 1024 * 1024; // 1 MB
});
@@ -84,6 +86,24 @@ public async Task StartAsync(CancellationToken cancellationToken)
_app = builder.Build();
+ // Global exception handler — prevents stack trace leakage regardless of environment.
+ _app.UseExceptionHandler(errApp => errApp.Run(async ctx =>
+ {
+ ctx.Response.StatusCode = 500;
+ ctx.Response.ContentType = "text/plain";
+ await ctx.Response.WriteAsync("Internal server error", ctx.RequestAborted).ConfigureAwait(false);
+ }));
+
+ // Global security headers — runs before all registrar middleware/routes so
+ // A2A, webhook, and MCP endpoints get headers even when WebChannel is disabled.
+ _app.Use(async (context, next) =>
+ {
+ ApplySecurityHeaders(context.Response);
+ if (_tls)
+ context.Response.Headers.StrictTransportSecurity = "max-age=31536000; includeSubDomains";
+ await next(context).ConfigureAwait(false);
+ });
+
// Let each registrar map middleware and routes.
// Order matters: registrars are resolved in DI registration order.
foreach (var registrar in registrarList)
@@ -96,12 +116,12 @@ public async Task StartAsync(CancellationToken cancellationToken)
try
{
- await _app.StartAsync(cancellationToken);
+ await _app.StartAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
LogStartFailed(_logger, _port, ex);
- await _app.DisposeAsync();
+ await _app.DisposeAsync().ConfigureAwait(false);
_app = null;
}
}
@@ -110,7 +130,9 @@ public async Task StopAsync(CancellationToken cancellationToken)
{
if (_app is not null)
{
- await _app.StopAsync(cancellationToken);
+ await _app.StopAsync(cancellationToken).ConfigureAwait(false);
+ await _app.DisposeAsync().ConfigureAwait(false);
+ _app = null;
}
}
@@ -118,7 +140,8 @@ public async ValueTask DisposeAsync()
{
if (_app is not null)
{
- await _app.DisposeAsync();
+ await _app.DisposeAsync().ConfigureAwait(false);
+ _app = null;
}
}
@@ -137,4 +160,17 @@ public async ValueTask DisposeAsync()
Message = "TLS is enabled in config but Kestrel is not configured for TLS directly. " +
"Configure a reverse proxy (nginx, Caddy, Traefik) to handle TLS termination on port {Port}.")]
private static partial void LogTlsAdvisory(ILogger logger, int port);
+
+ ///
+ /// Applies baseline security headers to all HTTP responses regardless of
+ /// which implementations are active.
+ ///
+ private static void ApplySecurityHeaders(HttpResponse response)
+ {
+ response.Headers.XContentTypeOptions = "nosniff";
+ response.Headers["Referrer-Policy"] = "no-referrer";
+ response.Headers.XFrameOptions = "DENY";
+ response.Headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(), usb=(), payment=()";
+ response.Headers.XXSSProtection = "1; mode=block";
+ }
}
diff --git a/src/clawsharp/Core/Pipeline/AgentLoop.OrgCommands.cs b/src/clawsharp/Core/Pipeline/AgentLoop.OrgCommands.cs
index 6c2719c8..a4c0c1aa 100644
--- a/src/clawsharp/Core/Pipeline/AgentLoop.OrgCommands.cs
+++ b/src/clawsharp/Core/Pipeline/AgentLoop.OrgCommands.cs
@@ -4,6 +4,7 @@
using Clawsharp.Cost;
using Clawsharp.Core.Sessions;
using Clawsharp.Organization;
+using Clawsharp.Tools;
namespace Clawsharp.Core.Pipeline;
@@ -50,7 +51,7 @@ private string HandleOrgExplain(Session session, string? argument)
var abacRules = _appConfig.Organization.Policies?.Rules;
if (abacRules is { Count: > 0 })
{
- var ctx = new AbacContext(orgUser, Clawsharp.Core.Utilities.ChannelName.Cli, DateTimeOffset.UtcNow);
+ var ctx = new AbacContext(orgUser, Utilities.ChannelName.Cli, DateTimeOffset.UtcNow);
abacDecision = evaluator.ApplyAbacRules(rbacDecision, abacRules, ctx);
}
@@ -94,7 +95,7 @@ private string HandleOrgSimulate(Session session, string? argument)
var abacRules = _appConfig.Organization.Policies?.Rules;
if (abacRules is { Count: > 0 })
{
- var ctx = new AbacContext(orgUser, Clawsharp.Core.Utilities.ChannelName.Cli, DateTimeOffset.UtcNow);
+ var ctx = new AbacContext(orgUser, Utilities.ChannelName.Cli, DateTimeOffset.UtcNow);
decision = evaluator.ApplyAbacRules(rbacDecision, abacRules, ctx);
}
else
@@ -119,7 +120,7 @@ private string HandleOrgSimulate(Session session, string? argument)
: (0m, 0m);
decimal? deptMonthlyUsed = null;
- Config.Organization.BudgetLimits? deptBudget = null;
+ BudgetLimits? deptBudget = null;
if (orgUser.Department is not null)
{
var (_, deptMonthly) = _costTracker.GetScopeTotals($"dept:{orgUser.Department}");
@@ -311,7 +312,7 @@ internal static string HandleOrgUsage(Session session, string? argument, AppConf
///
private async Task HandleOrgApproveAsync(Session session, string? argument, CancellationToken ct)
{
- var (success, message) = HandleOrgApprove(session, argument, _appConfig, _orgServices.ApprovalQueue);
+ var (success, message) = HandleOrgApprove(session, argument, _appConfig, _orgServices.ApprovalQueue, _tools.GetToolSensitivity);
// Fire proactive notification on successful approval (D-04)
if (success && argument is not null)
@@ -335,7 +336,9 @@ private async Task HandleOrgApproveAsync(Session session, string? argume
/// Handles /org approve <id> [--ttl <duration>] — admin-only (per D-18).
/// Internal static for testability via InternalsVisibleTo.
///
- internal static (bool Success, string Message) HandleOrgApprove(Session session, string? argument, AppConfig appConfig, ApprovalQueue approvalQueue)
+ internal static (bool Success, string Message) HandleOrgApprove(
+ Session session, string? argument, AppConfig appConfig, ApprovalQueue approvalQueue,
+ Func? getToolSensitivity = null)
{
if (appConfig.Organization is null)
return (false, "Organization mode is not enabled.");
@@ -375,6 +378,21 @@ internal static (bool Success, string Message) HandleOrgApprove(Session session,
if (request.State != ApprovalState.Pending)
return (false, "Request is no longer pending.");
+ // CVE-2026-33579 mitigation: validate that the approver's own policy allows this tool.
+ // An admin whose policy restricts them to low-sensitivity tools should not be able to
+ // approve requests for tools they cannot use themselves.
+ if (getToolSensitivity is not null
+ && session.CurrentPolicy is { } callerPolicy
+ && callerPolicy != PolicyDecision.Unrestricted)
+ {
+ var toolSensitivity = getToolSensitivity(request.ToolName);
+ var effect = callerPolicy.EvaluateToolAccess(request.ToolName, toolSensitivity);
+ // Allow if the tool would be Allowed or ApprovalRequired for the admin's own policy.
+ // Deny if DeniedBySensitivity, DeniedByGlob, or DeniedByAbac.
+ if (effect is not (PolicyEffect.Allowed or PolicyEffect.ApprovalRequired))
+ return (false, $"Cannot approve '{request.ToolName}' — your own policy does not allow this tool.");
+ }
+
var grant = approvalQueue.Approve(requestId, session.CurrentUser.Name, ttl);
if (grant is null)
return (false, "Request is no longer pending.");
@@ -477,13 +495,13 @@ await ConfigMutator.MutateConfigAsync(root =>
if (userNode is null) return;
userNode["roles"] = new System.Text.Json.Nodes.JsonArray(newRole);
- }, ct);
+ }, ct).ConfigureAwait(false);
// Replace OrgUserConfig with a new instance carrying the updated role list.
// Never mutate the shared List — concurrent readers may be iterating it.
if (_appConfig.Organization is { } org && org.Users.TryGetValue(username, out var userConfig))
{
- var updatedConfig = new Config.Organization.OrgUserConfig
+ var updatedConfig = new OrgUserConfig
{
Ids = new List(userConfig.Ids),
Roles = [newRole],
@@ -538,6 +556,23 @@ internal static (bool Success, string Message) HandleOrgSetRole(Session session,
return (false, $"Role not found: {newRole}. Available roles: {available}");
}
+ // CVE-2026-33579 mitigation: validate that the caller's own policy is at least as
+ // permissive as the target role. An admin with restricted scope should not be able
+ // to assign a role that grants broader privileges than they themselves hold.
+ var targetRole = roles[newRole];
+ if (session.CurrentPolicy is { } callerPolicy && callerPolicy != PolicyDecision.Unrestricted)
+ {
+ if (targetRole.IsUnrestrictedToolAccess && !callerPolicy.IsUnrestrictedToolAccess)
+ return (false, $"Cannot assign role '{newRole}' — it grants unrestricted tool access that exceeds your own policy.");
+
+ var targetSensitivity = ToolSensitivityParser.Parse(targetRole.MaxToolSensitivity);
+ if (targetSensitivity > callerPolicy.MaxSensitivity)
+ return (false, $"Cannot assign role '{newRole}' — its tool sensitivity ceiling exceeds your own.");
+
+ if (targetRole.IsUnrestrictedModels && !callerPolicy.IsUnrestrictedModels)
+ return (false, $"Cannot assign role '{newRole}' — it grants unrestricted model access that exceeds your own policy.");
+ }
+
return (true, $"Role updated: @{username} is now [{newRole}]. Change is effective immediately.");
}
@@ -693,13 +728,13 @@ await ConfigMutator.MutateConfigAsync(root =>
if (userNode is null) return;
userNode["ids"] = new System.Text.Json.Nodes.JsonArray();
- }, ct);
+ }, ct).ConfigureAwait(false);
// Replace OrgUserConfig with a new instance carrying an empty Ids list.
// Never mutate the shared List — concurrent readers may be iterating it.
if (_appConfig.Organization is { } org && org.Users.TryGetValue(username, out var userConfig))
{
- var updatedConfig = new Config.Organization.OrgUserConfig
+ var updatedConfig = new OrgUserConfig
{
Ids = [],
Roles = new List(userConfig.Roles),
diff --git a/src/clawsharp/Core/Pipeline/AgentLoop.Pipeline.cs b/src/clawsharp/Core/Pipeline/AgentLoop.Pipeline.cs
index a3a7bf19..88d0992f 100644
--- a/src/clawsharp/Core/Pipeline/AgentLoop.Pipeline.cs
+++ b/src/clawsharp/Core/Pipeline/AgentLoop.Pipeline.cs
@@ -67,6 +67,9 @@ private async Task> ApplyContextWindowGuardAsync(
{
LogContextWindowCompacting(estimated, contextWindow);
+ // Compaction bypasses the mediator pipeline intentionally: it requires
+ // direct provider/model parameters and post-compaction session mutation
+ // that don't fit the handler's command/result abstraction cleanly.
if (compConfig.PreCompactionMemoryFlush)
{
var recentStart = Math.Max(1, messages.Count - compConfig.KeepRecent);
@@ -276,12 +279,12 @@ await channel.SendAsync(
if (loopResult.CacheRead > 0)
{
ClawsharpMetrics.TokenUsage.Record(loopResult.CacheRead,
- new GenAiMetricTags { OperationName = "chat", Model = normalizedModel, TokenType = "cache_read" });
+ new GenAiMetricTags { OperationName = "chat", Model = normalizedModel, TokenType = "input_cached" });
}
// MET-02: LLM operation duration histogram
ClawsharpMetrics.OperationDuration.Record(sw.Elapsed.TotalSeconds,
- new GenAiMetricTags { OperationName = "chat", Model = normalizedModel, TokenType = "" });
+ new DurationMetricTags { OperationName = "chat", Model = normalizedModel });
await _handlers.RecordUsage.HandleAsync(new RecordUsage.Command(
sessionId, actualModel, inputDelta, outputDelta,
@@ -293,17 +296,21 @@ await _handlers.RecordUsage.HandleAsync(new RecordUsage.Command(
// Record interaction analytics (fire-and-forget — must not block the response pipeline).
if (_analytics.Enabled && loopResult.Reply is not null)
{
+ var sanitizedResponse = LeakDetector.Scan(loopResult.Reply).Redacted;
+ var sanitizedThinking = loopResult.Thinking is not null
+ ? LeakDetector.Scan(loopResult.Thinking).Redacted
+ : null;
var interactionInput = new InteractionInput(
SessionId: sessionId, Channel: inbound.Channel.Value, Model: actualModel,
UserPrompt: messages.LastOrDefault(m => m.Role == MessageRole.User)?.Content ?? "",
- Thinking: loopResult.Thinking, Response: loopResult.Reply,
+ Thinking: sanitizedThinking, Response: sanitizedResponse,
ToolCalls: loopResult.ToolCallSummaries, ToolIterations: loopResult.ToolIterations,
InputTokens: inputDelta, OutputTokens: outputDelta,
CacheReadTokens: loopResult.CacheRead, CacheWriteTokens: loopResult.CacheWrite,
DurationMs: sw.ElapsedMilliseconds);
SpanIsolation.RunFireAndForget("analytics.record", ClawsharpActivitySources.Pipeline, async () =>
{
- await _analytics.InteractionTracker.RecordAsync(interactionInput, CancellationToken.None);
+ await _analytics.InteractionTracker.RecordAsync(interactionInput, CancellationToken.None).ConfigureAwait(false);
});
}
@@ -505,14 +512,13 @@ private async Task PostProcessReplyAsync(
{
try
{
- var sb = new StringBuilder();
- for (var i = 0; i < audioChunks.Count; i++)
+ using var ms = new MemoryStream();
+ foreach (var chunk in audioChunks)
{
- sb.Append(i < audioChunks.Count - 1
- ? audioChunks[i].TrimEnd('=')
- : audioChunks[i]);
+ var bytes = Convert.FromBase64String(chunk);
+ ms.Write(bytes);
}
- var audioBytes = Convert.FromBase64String(sb.ToString());
+ var audioBytes = ms.ToArray();
var audioExt = AudioAttachment.FormatToExtension(loopResult.AudioFormat ?? "wav");
PendingFileStore.Enqueue(new PendingFile($"generated-audio{audioExt}", audioBytes, loopResult.AudioTranscript));
}
@@ -536,7 +542,7 @@ private async Task PostProcessReplyAsync(
var messagesSnapshot = session.Messages.ToList();
SpanIsolation.RunFireAndForget("memory.consolidate", ClawsharpActivitySources.Memory, async () =>
{
- await ConsolidateMemoryAsync(messagesSnapshot, CancellationToken.None);
+ await ConsolidateMemoryAsync(messagesSnapshot, CancellationToken.None).ConfigureAwait(false);
});
}
@@ -572,7 +578,7 @@ private void TriggerFactExtraction(string sessionId, string userText, string rep
SpanIsolation.RunFireAndForget("memory.extract_facts", ClawsharpActivitySources.Memory, async () =>
{
await _handlers.ExtractFacts.HandleAsync(
- new ExtractFacts.Command(conversationText), CancellationToken.None);
+ new ExtractFacts.Command(conversationText), CancellationToken.None).ConfigureAwait(false);
});
}
@@ -615,7 +621,7 @@ private async Task FlushMemoryBeforeCompactionAsync(
MaxTokens: 800
);
- var resp = await _provider.ChatAsync(flushReq, ct);
+ var resp = await _provider.ChatAsync(flushReq, ct).ConfigureAwait(false);
if (resp.Content is { Length: > 0 } facts &&
!facts.Contains("(nothing to save)", StringComparison.OrdinalIgnoreCase))
{
@@ -627,7 +633,7 @@ private async Task FlushMemoryBeforeCompactionAsync(
facts = scrubResult.Redacted;
}
- await _memory.AppendHistoryAsync(facts, ct);
+ await _memory.AppendHistoryAsync(facts, ct).ConfigureAwait(false);
LogPreCompactionFlushComplete(messagesToDiscard.Count, facts.Length);
}
}
@@ -662,7 +668,7 @@ private async Task ConsolidateMemoryAsync(List messages, Cancellati
MaxTokens: 500
);
- var summaryResp = await _provider.ChatAsync(summaryRequest, ct);
+ var summaryResp = await _provider.ChatAsync(summaryRequest, ct).ConfigureAwait(false);
if (summaryResp.Content is { Length: > 0 } summary)
{
// Scrub secrets from LLM summary before persisting to memory
@@ -673,7 +679,7 @@ private async Task ConsolidateMemoryAsync(List messages, Cancellati
summary = scrubResult.Redacted;
}
- await _memory.AppendHistoryAsync(summary, ct);
+ await _memory.AppendHistoryAsync(summary, ct).ConfigureAwait(false);
}
}
catch (Exception ex)
diff --git a/src/clawsharp/Core/Pipeline/AgentLoop.SlashCommands.cs b/src/clawsharp/Core/Pipeline/AgentLoop.SlashCommands.cs
index cee8de74..df328caf 100644
--- a/src/clawsharp/Core/Pipeline/AgentLoop.SlashCommands.cs
+++ b/src/clawsharp/Core/Pipeline/AgentLoop.SlashCommands.cs
@@ -23,14 +23,14 @@ public sealed partial class AgentLoop
switch (cmd)
{
case SlashCommandResult.ClearSession:
- await _handlers.ClearSession.HandleAsync(new ClearSession.Command(session), ct);
+ await _handlers.ClearSession.HandleAsync(new ClearSession.Command(session), ct).ConfigureAwait(false);
return "Session cleared.";
case SlashCommandResult.SendStatus:
var factCount = 0;
try
{
- var ctx = await _memory.GetContextAsync(ct);
+ var ctx = await _memory.GetContextAsync(ct).ConfigureAwait(false);
factCount = ctx?.Split('\n').Length ?? 0;
}
catch
@@ -58,10 +58,10 @@ public sealed partial class AgentLoop
var msgs = new List(session.Messages);
var compacted = await _compactionService.CompactAsync(
msgs, _provider, _defaults.Model,
- compConfig.KeepRecent, compConfig.MaxSummaryChars, compConfig.MaxSourceChars, ct);
+ compConfig.KeepRecent, compConfig.MaxSummaryChars, compConfig.MaxSourceChars, ct).ConfigureAwait(false);
session.Messages.Clear();
session.Messages.AddRange(compacted.Where(m => m.Role != MessageRole.System));
- await _handlers.SaveSession.HandleAsync(new SaveSession.Command(session), ct);
+ await _handlers.SaveSession.HandleAsync(new SaveSession.Command(session), ct).ConfigureAwait(false);
return $"Compacted: {msgs.Count} -> {session.Messages.Count} messages.";
case SlashCommandResult.ShowUsage:
@@ -70,7 +70,7 @@ public sealed partial class AgentLoop
return "Cost tracking is not enabled.\nSet cost.enabled: true in config to enable it.";
}
- var summary = await _handlers.GetCostSummary.HandleAsync(new GetCostSummary.Query(session.Id), ct);
+ var summary = await _handlers.GetCostSummary.HandleAsync(new GetCostSummary.Query(session.Id), ct).ConfigureAwait(false);
var usageSb = new StringBuilder();
usageSb.AppendLine($"Usage (today): ${summary.Daily:F4}");
usageSb.AppendLine($"Usage (this month): ${summary.Monthly:F4}");
@@ -101,7 +101,7 @@ public sealed partial class AgentLoop
if (_provider is OpenRouterProvider orProvider)
{
- var keyInfo = await orProvider.GetKeyInfoAsync(ct);
+ var keyInfo = await orProvider.GetKeyInfoAsync(ct).ConfigureAwait(false);
if (keyInfo is not null)
{
usageSb.AppendLine();
@@ -135,30 +135,30 @@ public sealed partial class AgentLoop
case SlashCommandResult.ThinkOn:
session.ShowThinking = true;
- await _handlers.SaveSession.HandleAsync(new SaveSession.Command(session), ct);
+ await _handlers.SaveSession.HandleAsync(new SaveSession.Command(session), ct).ConfigureAwait(false);
return "Thinking mode on. Reasoning blocks will be shown in replies.";
case SlashCommandResult.ThinkOff:
session.ShowThinking = false;
- await _handlers.SaveSession.HandleAsync(new SaveSession.Command(session), ct);
+ await _handlers.SaveSession.HandleAsync(new SaveSession.Command(session), ct).ConfigureAwait(false);
return "Thinking mode off.";
case SlashCommandResult.ThinkToggle:
session.ShowThinking = !session.ShowThinking;
- await _handlers.SaveSession.HandleAsync(new SaveSession.Command(session), ct);
+ await _handlers.SaveSession.HandleAsync(new SaveSession.Command(session), ct).ConfigureAwait(false);
return $"Thinking mode {(session.ShowThinking ? "on" : "off")}.";
case SlashCommandResult.ShowGoals:
- return await HandleGoalsCommandAsync(null, ct);
+ return await HandleGoalsCommandAsync(null, ct).ConfigureAwait(false);
case SlashCommandResult.ClearGoals:
- return await HandleGoalsCommandAsync("clear", ct);
+ return await HandleGoalsCommandAsync("clear", ct).ConfigureAwait(false);
case SlashCommandResult.SetModel:
- return await HandleModelCommandAsync(session, argument, ct);
+ return await HandleModelCommandAsync(session, argument, ct).ConfigureAwait(false);
case SlashCommandResult.ListModels:
- return await HandleListModelsCommandAsync(argument, ct);
+ return await HandleListModelsCommandAsync(argument, ct).ConfigureAwait(false);
case SlashCommandResult.OrgExplain:
return HandleOrgExplain(session, argument);
@@ -176,7 +176,7 @@ public sealed partial class AgentLoop
return HandleOrgUsage(session, argument, _appConfig, _costTracker);
case SlashCommandResult.OrgApprove:
- return await HandleOrgApproveAsync(session, argument, ct);
+ return await HandleOrgApproveAsync(session, argument, ct).ConfigureAwait(false);
case SlashCommandResult.OrgDeny:
return HandleOrgDeny(session, argument, _appConfig, _orgServices.ApprovalQueue);
@@ -185,7 +185,7 @@ public sealed partial class AgentLoop
return HandleOrgCancel(session, _appConfig, _orgServices.ApprovalQueue);
case SlashCommandResult.OrgSetRole:
- return await HandleOrgSetRoleAsync(session, argument, ct);
+ return await HandleOrgSetRoleAsync(session, argument, ct).ConfigureAwait(false);
case SlashCommandResult.Link:
return HandleLink(session, _appConfig, _orgServices.LinkTokenStore);
@@ -194,7 +194,7 @@ public sealed partial class AgentLoop
return HandleWhoami(session, _appConfig, _costTracker);
case SlashCommandResult.OrgUnlink:
- return await HandleOrgUnlinkAsync(session, argument, ct);
+ return await HandleOrgUnlinkAsync(session, argument, ct).ConfigureAwait(false);
case SlashCommandResult.OrgUnknown:
return "Unknown /org subcommand. Available: explain, simulate, status, usage, quota, approve, deny, cancel, set-role, unlink";
@@ -239,7 +239,7 @@ private async Task HandleModelCommandAsync(Session session, string? argu
if (string.Equals(argument, "reset", StringComparison.OrdinalIgnoreCase))
{
session.ModelOverride = null;
- await _handlers.SaveSession.HandleAsync(new SaveSession.Command(session), ct);
+ await _handlers.SaveSession.HandleAsync(new SaveSession.Command(session), ct).ConfigureAwait(false);
return $"Model reset to config default: {_defaults.Model}";
}
@@ -252,7 +252,7 @@ private async Task HandleModelCommandAsync(Session session, string? argu
return denial;
session.ModelOverride = trimmedArg;
- await _handlers.SaveSession.HandleAsync(new SaveSession.Command(session), ct);
+ await _handlers.SaveSession.HandleAsync(new SaveSession.Command(session), ct).ConfigureAwait(false);
return $"Model set to: {session.ModelOverride} (for this session)";
}
@@ -282,7 +282,7 @@ private async Task HandleGoalsCommandAsync(string? subcommand, Cancellat
{
try
{
- var goals = await _analytics.GoalStorage.LoadAsync(ct);
+ var goals = await _analytics.GoalStorage.LoadAsync(ct).ConfigureAwait(false);
var cleared = 0;
foreach (var g in goals.Where(g => g.Status == GoalStatus.Active || g.Status == GoalStatus.Paused))
{
@@ -291,7 +291,7 @@ private async Task HandleGoalsCommandAsync(string? subcommand, Cancellat
cleared++;
}
- await _analytics.GoalStorage.SaveAsync(goals, ct);
+ await _analytics.GoalStorage.SaveAsync(goals, ct).ConfigureAwait(false);
return cleared > 0 ? $"Cleared {cleared} goal(s)." : "No active or paused goals to clear.";
}
catch (Exception)
@@ -303,7 +303,7 @@ private async Task HandleGoalsCommandAsync(string? subcommand, Cancellat
// Default: list active goals
try
{
- var goals = await _analytics.GoalStorage.LoadAsync(ct);
+ var goals = await _analytics.GoalStorage.LoadAsync(ct).ConfigureAwait(false);
var active = goals.Where(g => g.Status == GoalStatus.Active || g.Status == GoalStatus.Paused).ToList();
if (active.Count == 0)
{
@@ -334,7 +334,7 @@ private async Task HandleListModelsCommandAsync(string? argument, Cancel
return "Model listing is currently only available for the OpenRouter provider.";
}
- var allModels = await modelsProvider.ListModelsAsync(ct);
+ var allModels = await modelsProvider.ListModelsAsync(ct).ConfigureAwait(false);
if (allModels.Count == 0)
{
return "Unable to fetch models from OpenRouter.";
@@ -433,6 +433,9 @@ private Task HandleKnowledgeStatusAsync(CancellationToken ct)
///
/// Handles /knowledge ingest — delegates to
/// when the knowledge system is enabled; returns an informative message otherwise.
+ /// No admin gate: knowledge commands are available to all authenticated users because
+ /// operators control which sources exist via config. Users can only re-trigger ingestion
+ /// of operator-configured sources, not specify arbitrary paths.
///
private Task HandleKnowledgeIngestAsync(string? argument, CancellationToken ct)
{
diff --git a/src/clawsharp/Core/Pipeline/AgentLoop.Streaming.cs b/src/clawsharp/Core/Pipeline/AgentLoop.Streaming.cs
index cec026ec..bc07b40e 100644
--- a/src/clawsharp/Core/Pipeline/AgentLoop.Streaming.cs
+++ b/src/clawsharp/Core/Pipeline/AgentLoop.Streaming.cs
@@ -5,6 +5,7 @@
using Clawsharp.Channels;
using Clawsharp.Cost;
using Clawsharp.Providers;
+using Clawsharp.Security;
using Clawsharp.Telemetry;
using Clawsharp.Core.Services;
using Clawsharp.Core.Sessions;
@@ -72,15 +73,15 @@ private async Task RunStreamingLoopAsync(
// Forward text deltas to the channel while consuming.
try
{
- await streamingChannel.StreamAsync(outbound, pipe.Reader.ReadAllAsync(ct), ct);
+ await streamingChannel.StreamAsync(outbound, pipe.Reader.ReadAllAsync(ct), ct).ConfigureAwait(false);
}
- catch (Exception ex)
+ catch (Exception ex) when (ex is not OperationCanceledException)
{
LogStreamingChannelError(_logger, ex);
}
// Wait for the producer to finish accumulating tool calls.
- var result = await consumeTask;
+ var result = await consumeTask.ConfigureAwait(false);
// ── Telemetry: post-call LLM span enrichment ─────────────────
llmActivity?.SetTag(GenAiAttributes.UsageInputTokens, result.InputTokens);
@@ -126,23 +127,12 @@ private async Task RunStreamingLoopAsync(
// MET-07 / LLM-04: TPOT histogram (average inter-token latency)
var tpot = StreamingMetricsHelper.ComputeTpot(result.StreamDuration, result.Ttft ?? TimeSpan.Zero, result.OutputTokens);
- if (result.Ttft is not null && tpot is { } tpotValue)
+ if (result.Ttft is not null && tpot is { } tpotValue && tpotValue >= 0)
{
ClawsharpMetrics.Tpot.Record(tpotValue,
new StreamingMetricTags { Model = normalizedModel, Channel = channelName });
}
- // D-12: Token usage + duration (same as non-streaming path)
- ClawsharpMetrics.TokenUsage.Record(result.InputTokens,
- new GenAiMetricTags { OperationName = "chat", Model = normalizedModel, TokenType = "input" });
- ClawsharpMetrics.TokenUsage.Record(result.OutputTokens,
- new GenAiMetricTags { OperationName = "chat", Model = normalizedModel, TokenType = "output" });
- if (result.CacheReadTokens > 0)
- ClawsharpMetrics.TokenUsage.Record(result.CacheReadTokens,
- new GenAiMetricTags { OperationName = "chat", Model = normalizedModel, TokenType = "cache_read" });
- ClawsharpMetrics.OperationDuration.Record(result.StreamDuration.TotalSeconds,
- new GenAiMetricTags { OperationName = "chat", Model = normalizedModel, TokenType = "" });
-
// Update session token counts from streaming usage data.
session.TotalInputTokens += result.InputTokens;
session.TotalOutputTokens += result.OutputTokens;
@@ -203,15 +193,21 @@ private async Task RunStreamingLoopAsync(
if (toolCalls?.Count > 0)
{
completedIterations++;
- toolCallSummaries ??= [];
- foreach (var tc in toolCalls)
- {
- toolCallSummaries.Add(new ToolCallSummary { Name = tc.Name, ResultLength = tc.ArgumentsJson.Length });
- }
// Add the assistant's turn (which may include streaming text + tool calls) to history.
messages.Add(new ChatMessage(MessageRole.Assistant, assistantText, ToolCalls: toolCalls));
- await ExecuteToolCallsAsync(toolCalls, messages, ct);
+ await ExecuteToolCallsAsync(toolCalls, messages, ct).ConfigureAwait(false);
+
+ // Build summaries from actual tool results (last N messages are tool results).
+ toolCallSummaries ??= [];
+ for (var i = messages.Count - toolCalls.Count; i < messages.Count; i++)
+ {
+ toolCallSummaries.Add(new ToolCallSummary
+ {
+ Name = messages[i].Name ?? "unknown",
+ ResultLength = messages[i].Content?.Length ?? 0
+ });
+ }
request = request with { Messages = messages };
continue; // next streaming iteration
@@ -255,8 +251,10 @@ private async Task ConsumeProviderStreamAsync(
bool showThinking,
CancellationToken ct)
{
+ const int leakScanBufferThreshold = 512;
var textSb = new StringBuilder();
var thinkingSb = new StringBuilder();
+ var streamLeakBuffer = new StringBuilder(leakScanBufferThreshold);
var emittedThinkingOpen = false;
var toolBuilders = new Dictionary();
var inputTokens = 0;
@@ -275,7 +273,7 @@ private async Task ConsumeProviderStreamAsync(
try
{
- await foreach (var chunk in _fallbackChain.ExecuteStreamAsync(candidates, request, ct, ApplyModelOverride))
+ await foreach (var chunk in _fallbackChain.ExecuteStreamAsync(candidates, request, ct, ApplyModelOverride).ConfigureAwait(false))
{
switch (chunk)
{
@@ -290,11 +288,17 @@ private async Task ConsumeProviderStreamAsync(
if (emittedThinkingOpen)
{
emittedThinkingOpen = false;
- await pipeWriter.WriteAsync("\n\n\n", ct);
+ await pipeWriter.WriteAsync("\n\n\n", ct).ConfigureAwait(false);
}
textSb.Append(td.Delta);
- await pipeWriter.WriteAsync(td.Delta, ct);
+ streamLeakBuffer.Append(td.Delta);
+ if (streamLeakBuffer.Length >= leakScanBufferThreshold)
+ {
+ var scanned = LeakDetector.Scan(streamLeakBuffer.ToString());
+ await pipeWriter.WriteAsync(scanned.Redacted, ct).ConfigureAwait(false);
+ streamLeakBuffer.Clear();
+ }
break;
case ThinkingDeltaChunk tk:
@@ -307,10 +311,10 @@ private async Task ConsumeProviderStreamAsync(
if (!emittedThinkingOpen)
{
emittedThinkingOpen = true;
- await pipeWriter.WriteAsync("\n", ct);
+ await pipeWriter.WriteAsync("\n", ct).ConfigureAwait(false);
}
- await pipeWriter.WriteAsync(tk.Delta, ct);
+ await pipeWriter.WriteAsync(tk.Delta, ct).ConfigureAwait(false);
}
break;
@@ -367,7 +371,7 @@ private async Task ConsumeProviderStreamAsync(
if (emittedThinkingOpen)
{
emittedThinkingOpen = false;
- await pipeWriter.WriteAsync("\n\n\n", ct);
+ await pipeWriter.WriteAsync("\n\n\n", ct).ConfigureAwait(false);
}
break;
@@ -387,6 +391,11 @@ private async Task ConsumeProviderStreamAsync(
}
finally
{
+ if (streamLeakBuffer.Length > 0)
+ {
+ var scanned = LeakDetector.Scan(streamLeakBuffer.ToString());
+ await pipeWriter.WriteAsync(scanned.Redacted, ct).ConfigureAwait(false);
+ }
pipeWriter.Complete();
}
@@ -411,19 +420,16 @@ private async Task ConsumeProviderStreamAsync(
return null;
}
- return toolBuilders
- .OrderBy(kv => kv.Key)
- .Select(kv =>
- {
- var args = "{}";
- if (kv.Value.Args.Length > 0)
- {
- args = kv.Value.Args.ToString();
- }
-
- return new ToolCall(kv.Value.Id, kv.Value.Name, args);
- })
- .ToList();
+ var sortedKeys = toolBuilders.Keys.ToArray();
+ Array.Sort(sortedKeys);
+ var result = new List(sortedKeys.Length);
+ foreach (var idx in sortedKeys)
+ {
+ var (id, name, args) = toolBuilders[idx];
+ result.Add(new ToolCall(id, name, args.Length > 0 ? args.ToString() : "{}"));
+ }
+
+ return result;
}
///
diff --git a/src/clawsharp/Core/Pipeline/AgentLoop.ToolExecution.cs b/src/clawsharp/Core/Pipeline/AgentLoop.ToolExecution.cs
index 7656c92b..d88c1fec 100644
--- a/src/clawsharp/Core/Pipeline/AgentLoop.ToolExecution.cs
+++ b/src/clawsharp/Core/Pipeline/AgentLoop.ToolExecution.cs
@@ -17,6 +17,11 @@ public sealed partial class AgentLoop
/// streaming and non-streaming loops. When multiple tool calls are present, they
/// are executed concurrently via and results are
/// appended in the original order for deterministic behavior.
+ ///
+ /// Tool execution bypasses the mediator pipeline intentionally — authorization
+ /// and RBAC filtering are enforced directly by at
+ /// definition-time (GetFilteredDefinitions) and execution-time (ExecuteAsync).
+ ///
///
private async Task ExecuteToolCallsAsync(
IReadOnlyList toolCalls,
@@ -31,7 +36,7 @@ private async Task ExecuteToolCallsAsync(
{
var tc = toolCalls[0];
LogToolExecution(_logger, tc.Name, tc.ArgumentsJson[..Math.Min(ToolArgsLogPreviewLength, tc.ArgumentsJson.Length)]);
- var result = await _tools.ExecuteAsync(tc.Name, tc.ArgumentsJson, ct);
+ var result = await _tools.ExecuteAsync(tc.Name, tc.ArgumentsJson, ct).ConfigureAwait(false);
result = ApplyToolResultGuard(tc, result, ct);
messages.Add(new ChatMessage(MessageRole.Tool, result, ToolCallId: tc.Id, Name: tc.Name));
}
@@ -45,7 +50,7 @@ private async Task ExecuteToolCallsAsync(
tasks[i] = _tools.ExecuteAsync(tc.Name, tc.ArgumentsJson, ct);
}
- var results = await Task.WhenAll(tasks);
+ var results = await Task.WhenAll(tasks).ConfigureAwait(false);
for (var i = 0; i < toolCalls.Count; i++)
{
diff --git a/src/clawsharp/Core/Pipeline/AgentLoop.cs b/src/clawsharp/Core/Pipeline/AgentLoop.cs
index 167c5a1f..80afb6bb 100644
--- a/src/clawsharp/Core/Pipeline/AgentLoop.cs
+++ b/src/clawsharp/Core/Pipeline/AgentLoop.cs
@@ -94,14 +94,14 @@ public sealed partial class AgentLoop
// ── Analytics, goals, and fact extraction — grouped behind AnalyticsServices ──
private readonly AnalyticsServices _analytics;
- /// Lazily-built candidate list for provider fallback. Built once on first use.
- private IReadOnlyList<(string Name, IProvider Provider)>? _fallbackCandidates;
+ /// Pre-built candidate list for provider fallback. Built once in the constructor.
+ private readonly IReadOnlyList<(string Name, IProvider Provider)> _fallbackCandidates;
- /// Lazily-built candidate list filtered to streaming providers only. Built alongside .
- private IReadOnlyList<(string Name, IStreamingProvider Provider)>? _streamingFallbackCandidates;
+ /// Pre-built candidate list filtered to streaming providers only. Built alongside .
+ private readonly IReadOnlyList<(string Name, IStreamingProvider Provider)> _streamingFallbackCandidates;
/// Per-fallback model overrides keyed by provider name. Built alongside .
- private Dictionary? _fallbackModelOverrides;
+ private readonly Dictionary _fallbackModelOverrides;
/// Result of a single tool-loop execution (streaming or non-streaming).
private sealed record LoopResult(
@@ -169,6 +169,8 @@ public AgentLoop(
_webhookSlashCommandHandler = webhookSlashCommandHandler;
_knowledgeSlashCommandHandler = knowledgeSlashCommandHandler;
+ (_fallbackCandidates, _streamingFallbackCandidates, _fallbackModelOverrides) = BuildFallbackCandidates();
+
// MET-05: active session gauge — reports _sessionPipelines.Count on each scrape
ClawsharpMetrics.InitializeSessionGauge(() => _sessionPipelines.Count);
}
@@ -178,23 +180,28 @@ public async Task RunAsync(IMessageBus bus, CancellationToken ct = default)
// Dispatch each inbound message to the owning session's pipeline.
// Lazy guarantees StartSessionPipeline runs exactly once per key,
// even if multiple threads race on GetOrAdd for the same session.
- await foreach (var inbound in bus.ReadAllAsync(ct))
+ await foreach (var inbound in bus.ReadAllAsync(ct).ConfigureAwait(false))
{
var sessionId = $"{inbound.Channel.Value}:{inbound.SenderId}";
var lazy = _sessionPipelines.GetOrAdd(sessionId,
k => new Lazy<(Channel, Task)>(() => StartSessionPipeline(k, ct)));
- await lazy.Value.Ch.Writer.WriteAsync(inbound, ct);
+ await lazy.Value.Ch.Writer.WriteAsync(inbound, ct).ConfigureAwait(false);
}
// Await all drain tasks so exceptions are observed on shutdown.
- // Use a 5-second timeout so in-flight LLM calls don't block exit.
+ // Use a dedicated 5-second timeout since `ct` is already cancelled at this point.
+ using var drainCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
foreach (var kvp in _sessionPipelines)
{
if (kvp.Value.IsValueCreated)
{
try
{
- await kvp.Value.Value.DrainTask.WaitAsync(TimeSpan.FromSeconds(5), ct);
+ await kvp.Value.Value.DrainTask.WaitAsync(drainCts.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ // 5-second drain window elapsed — abandon remaining in-flight work.
}
catch (TimeoutException)
{
@@ -218,13 +225,30 @@ public async Task RunAsync(IMessageBus bus, CancellationToken ct = default)
/// Processes all messages for one session in arrival order.
/// Runs until the channel is completed or is cancelled.
///
+ /// How long a session pipeline waits for the next message before self-evicting.
+ private static readonly TimeSpan SessionIdleTimeout = TimeSpan.FromMinutes(30);
+
private async Task DrainSessionAsync(string sessionId, ChannelReader reader, CancellationToken ct)
{
try
{
- await foreach (var inbound in reader.ReadAllAsync(ct))
+ while (!ct.IsCancellationRequested)
{
- await ProcessMessageAsync(inbound, ct);
+ using var idleCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ idleCts.CancelAfter(SessionIdleTimeout);
+
+ InboundMessage inbound;
+ try
+ {
+ inbound = await reader.ReadAsync(idleCts.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (!ct.IsCancellationRequested)
+ {
+ // Idle timeout — evict this session pipeline.
+ break;
+ }
+
+ await ProcessMessageAsync(inbound, ct).ConfigureAwait(false);
}
}
finally
@@ -292,6 +316,7 @@ internal async Task ProcessMessageAsync(InboundMessage inbound, CancellationToke
// Start thinking indicator (best-effort, fire-and-forget style).
var thinkingIndicator = channel as IThinkingIndicator;
+ var messageSw = Stopwatch.StartNew();
try
{
if (thinkingIndicator is not null)
@@ -617,6 +642,10 @@ await channel.SendAsync(
}
finally
{
+ messageSw.Stop();
+ ClawsharpMetrics.MessageDuration.Record(messageSw.Elapsed.TotalSeconds,
+ new PipelineMetricTags { Channel = inbound.Channel.Value });
+
// Stop thinking indicator (best-effort).
try
{
@@ -677,7 +706,7 @@ private async Task RunNonStreamingLoopAsync(
response = await _fallbackChain.ExecuteAsync(
candidates,
(name, provider, token) => provider.ChatAsync(ApplyModelOverride(name, request), token),
- ct);
+ ct).ConfigureAwait(false);
}
catch (FallbackExhaustedException ex)
{
@@ -738,15 +767,20 @@ private async Task RunNonStreamingLoopAsync(
if (response.ToolCalls?.Count > 0)
{
completedIterations++;
+ messages.Add(new ChatMessage(MessageRole.Assistant, response.Content, ToolCalls: response.ToolCalls));
+ await ExecuteToolCallsAsync(response.ToolCalls, messages, ct).ConfigureAwait(false);
+
+ // Build summaries from actual tool results (last N messages are tool results).
toolCallSummaries ??= [];
- foreach (var tc in response.ToolCalls)
+ for (var i = messages.Count - response.ToolCalls.Count; i < messages.Count; i++)
{
- toolCallSummaries.Add(new ToolCallSummary { Name = tc.Name, ResultLength = tc.ArgumentsJson.Length });
+ toolCallSummaries.Add(new ToolCallSummary
+ {
+ Name = messages[i].Name ?? "unknown",
+ ResultLength = messages[i].Content?.Length ?? 0
+ });
}
- messages.Add(new ChatMessage(MessageRole.Assistant, response.Content, ToolCalls: response.ToolCalls));
- await ExecuteToolCallsAsync(response.ToolCalls, messages, ct);
-
request = request with { Messages = messages };
continue;
}
@@ -768,17 +802,24 @@ private async Task RunNonStreamingLoopAsync(
// Fallback candidate management
// ──────────────────────────────────────────────────────────────────────
+ ///
+ /// Returns the pre-built ordered candidate list for the fallback chain.
+ ///
+ private IReadOnlyList<(string Name, IProvider Provider)> GetFallbackCandidates() => _fallbackCandidates;
+
+ ///
+ /// Returns the pre-built ordered candidate list filtered to streaming providers only.
+ ///
+ private IReadOnlyList<(string Name, IStreamingProvider Provider)> GetStreamingFallbackCandidates() => _streamingFallbackCandidates;
+
///
/// Builds the ordered candidate list for the fallback chain: primary provider first,
- /// then each configured fallback provider. Built once and cached.
+ /// then each configured fallback provider. Called once from the constructor.
///
- private IReadOnlyList<(string Name, IProvider Provider)> GetFallbackCandidates()
+ private (IReadOnlyList<(string Name, IProvider Provider)>,
+ IReadOnlyList<(string Name, IStreamingProvider Provider)>,
+ Dictionary) BuildFallbackCandidates()
{
- if (_fallbackCandidates is not null)
- {
- return _fallbackCandidates;
- }
-
var candidates = new List<(string Name, IProvider Provider)>
{
(_defaults.Provider, _provider)
@@ -824,27 +865,12 @@ private async Task RunNonStreamingLoopAsync(
}
}
- _fallbackModelOverrides = modelOverrides;
- _fallbackCandidates = candidates;
- _streamingFallbackCandidates = candidates
- .Where(c => c.Provider is IStreamingProvider)
- .Select(c => (c.Name, (IStreamingProvider)c.Provider))
- .ToList();
- return _fallbackCandidates;
- }
-
- ///
- /// Builds the ordered candidate list filtered to streaming providers only.
- ///
- private IReadOnlyList<(string Name, IStreamingProvider Provider)> GetStreamingFallbackCandidates()
- {
- if (_streamingFallbackCandidates is not null)
- {
- return _streamingFallbackCandidates;
- }
+ var streamingCandidates = candidates
+ .Where(c => c.Provider is IStreamingProvider)
+ .Select(c => (c.Name, (IStreamingProvider)c.Provider))
+ .ToList();
- GetFallbackCandidates();
- return _streamingFallbackCandidates!;
+ return (candidates, streamingCandidates, modelOverrides);
}
///
@@ -853,8 +879,7 @@ private async Task RunNonStreamingLoopAsync(
///
private ChatRequest ApplyModelOverride(string candidateName, ChatRequest request)
{
- if (_fallbackModelOverrides is not null
- && _fallbackModelOverrides.TryGetValue(candidateName, out var modelOverride))
+ if (_fallbackModelOverrides.TryGetValue(candidateName, out var modelOverride))
{
return request with { Model = modelOverride };
}
@@ -876,6 +901,12 @@ internal static List MergeConsecutiveRoles(List messag
return messages;
}
+ // Fast path: skip allocation when no adjacent same-role messages need merging.
+ if (!NeedsMerge(messages))
+ {
+ return messages;
+ }
+
var result = new List(messages.Count);
result.Add(messages[0]);
@@ -884,12 +915,16 @@ internal static List MergeConsecutiveRoles(List messag
var current = messages[i];
var previous = result[^1];
- // Only merge user<->user or assistant<->assistant (not system, not tool)
+ // Only merge user<->user or assistant<->assistant (not system, not tool).
+ // Never merge messages that carry multimodal attachments — the `with` expression
+ // would silently drop the current message's images/files/videos/audio.
if (current.Role == previous.Role
&& current.Role != MessageRole.System
&& current.Role != MessageRole.Tool
&& current.ToolCalls is null // don't merge assistant messages that have tool calls
- && previous.ToolCalls is null)
+ && previous.ToolCalls is null
+ && !HasAttachments(current)
+ && !HasAttachments(previous))
{
var merged = (previous.Content ?? "") + "\n\n" + (current.Content ?? "");
result[^1] = previous with { Content = merged.Trim() };
@@ -903,6 +938,31 @@ internal static List MergeConsecutiveRoles(List messag
return result;
}
+ private static bool NeedsMerge(List messages)
+ {
+ for (var i = 1; i < messages.Count; i++)
+ {
+ var current = messages[i];
+ var previous = messages[i - 1];
+ if (current.Role == previous.Role
+ && current.Role != MessageRole.System
+ && current.Role != MessageRole.Tool
+ && current.ToolCalls is null
+ && previous.ToolCalls is null
+ && !HasAttachments(current)
+ && !HasAttachments(previous))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static bool HasAttachments(ChatMessage m) =>
+ m.Images is { Count: > 0 } || m.Files is { Count: > 0 } ||
+ m.Videos is { Count: > 0 } || m.Audio is not null;
+
// ──────────────────────────────────────────────────────────────────────
// LoggerMessage declarations
// ──────────────────────────────────────────────────────────────────────
diff --git a/src/clawsharp/Core/Pipeline/AgentLoopService.cs b/src/clawsharp/Core/Pipeline/AgentLoopService.cs
index 429e3616..ffd4f974 100644
--- a/src/clawsharp/Core/Pipeline/AgentLoopService.cs
+++ b/src/clawsharp/Core/Pipeline/AgentLoopService.cs
@@ -25,7 +25,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
try
{
- await agentLoop.RunAsync(bus, stoppingToken);
+ await agentLoop.RunAsync(bus, stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
diff --git a/src/clawsharp/Core/Pipeline/SystemPrompt.cs b/src/clawsharp/Core/Pipeline/SystemPrompt.cs
index 9a735518..57bbf56f 100644
--- a/src/clawsharp/Core/Pipeline/SystemPrompt.cs
+++ b/src/clawsharp/Core/Pipeline/SystemPrompt.cs
@@ -12,7 +12,7 @@ public static string Build(
string? memoryContext = null,
string? workspaceContext = null,
string? channelName = null,
- IReadOnlyList? enabledTools = null,
+ IEnumerable? enabledTools = null,
string? activeGoalsContext = null)
{
var (staticPart, dynamicPart) = BuildSplit(memoryContext, workspaceContext, channelName, enabledTools, activeGoalsContext);
@@ -42,7 +42,7 @@ public static (string StaticPart, string DynamicPart) BuildSplit(
string? memoryContext = null,
string? workspaceContext = null,
string? channelName = null,
- IReadOnlyList? enabledTools = null,
+ IEnumerable? enabledTools = null,
string? activeGoalsContext = null)
{
var sb = new StringBuilder();
@@ -56,10 +56,14 @@ public static (string StaticPart, string DynamicPart) BuildSplit(
sb.AppendLine("You are clawsharp, a helpful AI assistant running on the user's own hardware.");
sb.AppendLine("Be concise, accurate, and helpful. When using tools, prefer the minimum necessary.");
- if (enabledTools is { Count: > 0 })
+ if (enabledTools is not null)
{
- sb.AppendLine();
- sb.AppendLine($"Available tools: {string.Join(", ", enabledTools)}");
+ var toolList = string.Join(", ", enabledTools);
+ if (toolList.Length > 0)
+ {
+ sb.AppendLine();
+ sb.AppendLine($"Available tools: {toolList}");
+ }
}
if (!string.IsNullOrWhiteSpace(memoryContext))
diff --git a/src/clawsharp/Core/Resilience/ChannelResilienceExtensions.cs b/src/clawsharp/Core/Resilience/ChannelResilienceExtensions.cs
index 9aa48d90..a02d5f6c 100644
--- a/src/clawsharp/Core/Resilience/ChannelResilienceExtensions.cs
+++ b/src/clawsharp/Core/Resilience/ChannelResilienceExtensions.cs
@@ -2,6 +2,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Polly;
+using Polly.Registry;
using Polly.Retry;
namespace Clawsharp.Core.Resilience;
diff --git a/src/clawsharp/Core/Security/AdminRoleFilter.cs b/src/clawsharp/Core/Security/AdminRoleFilter.cs
index c1eeae54..e4338920 100644
--- a/src/clawsharp/Core/Security/AdminRoleFilter.cs
+++ b/src/clawsharp/Core/Security/AdminRoleFilter.cs
@@ -1,3 +1,4 @@
+using Clawsharp.Config.Organization;
using Clawsharp.McpServer;
using Clawsharp.Organization;
using Microsoft.AspNetCore.Http;
@@ -10,7 +11,6 @@ namespace Clawsharp.Core.Security;
/// stored by that filter from .
/// Passes through when:
/// - The policy is (single-operator implicit admin), or
-/// - The policy has , or
/// - The resolved user has at least one role with .
/// Returns HTTP 403 (not 401) for authenticated but non-admin users per Pitfall 4.
/// Per D-24, D-26 of the v2.3 webhook design.
@@ -26,15 +26,13 @@ public sealed class AdminRoleFilter : IEndpointFilter
// D-26: Unrestricted policy = single-operator implicit admin
if (authResult.PolicyDecision == PolicyDecision.Unrestricted)
- return await next(ctx);
+ return await next(ctx).ConfigureAwait(false);
- // IsUnrestrictedToolAccess: granted when any role gives full tool access
- if (authResult.PolicyDecision.IsUnrestrictedToolAccess)
- return await next(ctx);
-
- // Check if user has any admin role in resolved policies
+ // Check if user has any admin role in resolved policies.
+ // Note: IsUnrestrictedToolAccess alone does NOT grant admin access — "can use all tools"
+ // is a separate concern from "can administer the system" (CWE-863 mitigation).
if (authResult.User?.ResolvedPolicies.Any(p => p.IsAdmin) == true)
- return await next(ctx);
+ return await next(ctx).ConfigureAwait(false);
// Authenticated but not admin — return 403 (not 401, not Results.Forbid() which triggers
// challenge middleware per research Pitfall 4)
diff --git a/src/clawsharp/Core/Security/ApiKeyAuthenticator.cs b/src/clawsharp/Core/Security/ApiKeyAuthenticator.cs
index 8d0a0c20..4125df48 100644
--- a/src/clawsharp/Core/Security/ApiKeyAuthenticator.cs
+++ b/src/clawsharp/Core/Security/ApiKeyAuthenticator.cs
@@ -49,12 +49,20 @@ public ApiKeyAuthenticator(
_requireAuth = config?.ApiKeys is not null || oidcService is not null;
// Pre-compute UTF-8 bytes for constant-time comparison (Pitfall 3).
+ // When entry.Secret is set, use it as the bearer token value (separates keyId from credential).
+ // When entry.Secret is null, fall back to keyId as the bearer token (backward compat, deprecated).
_apiKeyBytes = [];
if (config?.ApiKeys is not null)
{
foreach (var (keyId, entry) in config.ApiKeys)
{
- _apiKeyBytes.Add((Encoding.UTF8.GetBytes(keyId), keyId, entry));
+ var secret = entry.Secret ?? keyId;
+ _apiKeyBytes.Add((Encoding.UTF8.GetBytes(secret), keyId, entry));
+
+ if (entry.Secret is null)
+ {
+ LogApiKeyMissingSecret(_logger, keyId);
+ }
}
}
}
@@ -179,4 +187,9 @@ public bool IsLocalhostBypass(IPAddress? remoteAddress)
[LoggerMessage(EventId = 4, Level = LogLevel.Warning,
Message = "JWT Bearer validation error: {Error}")]
private static partial void LogJwtValidationError(ILogger logger, string error);
+
+ [LoggerMessage(EventId = 5, Level = LogLevel.Warning,
+ Message = "API key '{KeyId}' uses the dictionary key as the bearer secret (deprecated). " +
+ "Add a 'secret' field to separate the identifier from the credential.")]
+ private static partial void LogApiKeyMissingSecret(ILogger logger, string keyId);
}
diff --git a/src/clawsharp/Core/Security/BearerTokenAuthFilter.cs b/src/clawsharp/Core/Security/BearerTokenAuthFilter.cs
index bfc768a8..88401976 100644
--- a/src/clawsharp/Core/Security/BearerTokenAuthFilter.cs
+++ b/src/clawsharp/Core/Security/BearerTokenAuthFilter.cs
@@ -25,7 +25,7 @@ public sealed class BearerTokenAuthFilter(ApiKeyAuthenticator authenticator) : I
if (authenticator.IsLocalhostBypass(httpCtx.Connection.RemoteIpAddress))
{
httpCtx.Items[AuthResultKey] = McpServerAuthResult.Success(null, PolicyDecision.Unrestricted, null);
- return await next(ctx);
+ return await next(ctx).ConfigureAwait(false);
}
var authHeader = httpCtx.Request.Headers.Authorization.ToString();
@@ -40,6 +40,6 @@ public sealed class BearerTokenAuthFilter(ApiKeyAuthenticator authenticator) : I
return Results.Unauthorized();
httpCtx.Items[AuthResultKey] = result;
- return await next(ctx);
+ return await next(ctx).ConfigureAwait(false);
}
}
diff --git a/src/clawsharp/Core/Services/CooldownTracker.cs b/src/clawsharp/Core/Services/CooldownTracker.cs
index 84331cf6..c6cc9e75 100644
--- a/src/clawsharp/Core/Services/CooldownTracker.cs
+++ b/src/clawsharp/Core/Services/CooldownTracker.cs
@@ -79,18 +79,33 @@ public void RecordSuccess(string providerName)
///
private static TimeSpan ComputeCooldown(FailoverReason reason, int failureCount)
{
- if (reason == FailoverReason.Billing)
+ switch (reason)
{
- var exponent = Math.Min(failureCount - 1, 10);
- var hours = 5.0 * Math.Pow(2, exponent);
- return TimeSpan.FromHours(Math.Min(hours, 24));
- }
+ case FailoverReason.Billing:
+ {
+ var exponent = Math.Min(failureCount - 1, 10);
+ var hours = 5.0 * Math.Pow(2, exponent);
+ return TimeSpan.FromHours(Math.Min(hours, 24));
+ }
- // Standard backoff: 1 min * 5^min(n-1, 3)
- // n=1 → 1m, n=2 → 5m, n=3 → 25m, n=4+ → capped at 60m (1h)
- var exp = Math.Min(failureCount - 1, 3);
- var minutes = 1.0 * Math.Pow(5, exp);
- return TimeSpan.FromMinutes(Math.Min(minutes, 60));
+ // Overloaded uses the same standard backoff as RateLimit, Timeout, etc.
+ // Explicit case so intent is clear — the FailoverReason enum doc says
+ // "mapped to RateLimit behavior" and this keeps the two aligned.
+ case FailoverReason.Overloaded:
+ case FailoverReason.RateLimit:
+ case FailoverReason.Timeout:
+ case FailoverReason.Auth:
+ case FailoverReason.Format:
+ case FailoverReason.Unknown:
+ default:
+ {
+ // Standard backoff: 1 min * 5^min(n-1, 3)
+ // n=1 → 1m, n=2 → 5m, n=3 → 25m, n=4+ → capped at 60m (1h)
+ var exp = Math.Min(failureCount - 1, 3);
+ var minutes = 1.0 * Math.Pow(5, exp);
+ return TimeSpan.FromMinutes(Math.Min(minutes, 60));
+ }
+ }
}
/// Mutable state for a single provider's cooldown tracking.
diff --git a/src/clawsharp/Core/Services/CronService.cs b/src/clawsharp/Core/Services/CronService.cs
index f023b173..3f2e7f46 100644
--- a/src/clawsharp/Core/Services/CronService.cs
+++ b/src/clawsharp/Core/Services/CronService.cs
@@ -40,11 +40,11 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
- await store.InitAsync(stoppingToken);
+ await store.InitAsync(stoppingToken).ConfigureAwait(false);
- var loaded = await store.LoadAllAsync(stoppingToken);
+ var loaded = await store.LoadAllAsync(stoppingToken).ConfigureAwait(false);
- await _jobsLock.WaitAsync(stoppingToken);
+ await _jobsLock.WaitAsync(stoppingToken).ConfigureAwait(false);
try
{
foreach (var job in loaded)
@@ -87,7 +87,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
Provider = entry.Provider
};
_jobs[id] = job;
- await store.UpsertAsync(job, stoppingToken);
+ await store.UpsertAsync(job, stoppingToken).ConfigureAwait(false);
}
}
@@ -113,7 +113,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
// Wake up immediately if we already have enabled jobs
bool hasEnabledOnStart;
- await _jobsLock.WaitAsync(stoppingToken);
+ await _jobsLock.WaitAsync(stoppingToken).ConfigureAwait(false);
try
{
hasEnabledOnStart = _jobs.Values.Any(j => j.Enabled);
@@ -133,7 +133,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
- await _wakeSignal.WaitAsync(stoppingToken);
+ await _wakeSignal.WaitAsync(stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
@@ -149,12 +149,12 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Reload from the backing store so that jobs added externally
// (e.g. via CLI `clawsharp cron add`) are picked up without a restart.
- await ReloadFromStoreAsync(stoppingToken);
+ await ReloadFromStoreAsync(stoppingToken).ConfigureAwait(false);
- await FireDueJobsAsync(stoppingToken);
+ await FireDueJobsAsync(stoppingToken).ConfigureAwait(false);
bool hasEnabled;
- await _jobsLock.WaitAsync(stoppingToken);
+ await _jobsLock.WaitAsync(stoppingToken).ConfigureAwait(false);
try
{
hasEnabled = _jobs.Values.Any(j => j.Enabled);
@@ -170,7 +170,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
break;
}
- await Task.Delay(PollIntervalMs, stoppingToken);
+ await Task.Delay(PollIntervalMs, stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
@@ -184,8 +184,8 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
public async Task AddJobAsync(CronJob job, CancellationToken ct = default)
{
- await _initialized.Task.WaitAsync(ct);
- await _jobsLock.WaitAsync(ct);
+ await _initialized.Task.WaitAsync(ct).ConfigureAwait(false);
+ await _jobsLock.WaitAsync(ct).ConfigureAwait(false);
try
{
_jobs[job.Id] = job;
@@ -195,7 +195,7 @@ public async Task AddJobAsync(CronJob job, CancellationToken ct = defau
_jobsLock.Release();
}
- await store.UpsertAsync(job, ct);
+ await store.UpsertAsync(job, ct).ConfigureAwait(false);
if (job.Enabled)
{
@@ -208,8 +208,8 @@ public async Task AddJobAsync(CronJob job, CancellationToken ct = defau
public async Task> ListJobsAsync(CancellationToken ct = default)
{
- await _initialized.Task.WaitAsync(ct);
- await _jobsLock.WaitAsync(ct);
+ await _initialized.Task.WaitAsync(ct).ConfigureAwait(false);
+ await _jobsLock.WaitAsync(ct).ConfigureAwait(false);
try
{
return _jobs.Values.ToList();
@@ -222,9 +222,9 @@ public async Task> ListJobsAsync(CancellationToken ct = d
public async Task RemoveJobAsync(string id, CancellationToken ct = default)
{
- await _initialized.Task.WaitAsync(ct);
+ await _initialized.Task.WaitAsync(ct).ConfigureAwait(false);
bool removed;
- await _jobsLock.WaitAsync(ct);
+ await _jobsLock.WaitAsync(ct).ConfigureAwait(false);
try
{
removed = _jobs.Remove(id);
@@ -236,7 +236,7 @@ public async Task RemoveJobAsync(string id, CancellationToken ct = default
if (removed)
{
- await store.DeleteAsync(id, ct);
+ await store.DeleteAsync(id, ct).ConfigureAwait(false);
LogJobRemoved(logger, id);
}
@@ -245,8 +245,8 @@ public async Task RemoveJobAsync(string id, CancellationToken ct = default
public async Task UpdateJobAsync(CronJob job, CancellationToken ct = default)
{
- await _initialized.Task.WaitAsync(ct);
- await _jobsLock.WaitAsync(ct);
+ await _initialized.Task.WaitAsync(ct).ConfigureAwait(false);
+ await _jobsLock.WaitAsync(ct).ConfigureAwait(false);
try
{
if (!_jobs.ContainsKey(job.Id))
@@ -261,7 +261,7 @@ public async Task RemoveJobAsync(string id, CancellationToken ct = default
_jobsLock.Release();
}
- await store.UpsertAsync(job, ct);
+ await store.UpsertAsync(job, ct).ConfigureAwait(false);
if (job.Enabled)
{
@@ -273,9 +273,9 @@ public async Task RemoveJobAsync(string id, CancellationToken ct = default
public async Task RunJobNowAsync(string id, CancellationToken ct = default)
{
- await _initialized.Task.WaitAsync(ct);
+ await _initialized.Task.WaitAsync(ct).ConfigureAwait(false);
CronJob? job;
- await _jobsLock.WaitAsync(ct);
+ await _jobsLock.WaitAsync(ct).ConfigureAwait(false);
try
{
_jobs.TryGetValue(id, out job);
@@ -290,7 +290,7 @@ public async Task RunJobNowAsync(string id, CancellationToken ct = defau
return $"No job with id '{id}'.";
}
- await FireJobAsync(job, ct);
+ await FireJobAsync(job, ct).ConfigureAwait(false);
return $"Fired job '{job.Id}' ({job.Name ?? job.ScheduleExpr}).";
}
@@ -308,7 +308,7 @@ private async Task ReloadFromStoreAsync(CancellationToken ct)
IReadOnlyList stored;
try
{
- stored = await store.LoadAllAsync(ct);
+ stored = await store.LoadAllAsync(ct).ConfigureAwait(false);
}
catch (Exception ex) when (!ct.IsCancellationRequested)
{
@@ -316,7 +316,7 @@ private async Task ReloadFromStoreAsync(CancellationToken ct)
return; // proceed with stale in-memory data
}
- await _jobsLock.WaitAsync(ct);
+ await _jobsLock.WaitAsync(ct).ConfigureAwait(false);
try
{
var storeIds = new HashSet(stored.Count, StringComparer.Ordinal);
@@ -359,7 +359,7 @@ private async Task ReloadFromStoreAsync(CancellationToken ct)
private async Task FireDueJobsAsync(CancellationToken ct)
{
List snapshot;
- await _jobsLock.WaitAsync(ct);
+ await _jobsLock.WaitAsync(ct).ConfigureAwait(false);
try
{
snapshot = _jobs.Values.Where(j => j.Enabled).ToList();
@@ -380,7 +380,7 @@ private async Task FireDueJobsAsync(CancellationToken ct)
try
{
- await FireJobAsync(job, ct);
+ await FireJobAsync(job, ct).ConfigureAwait(false);
}
catch (Exception ex) when (!ct.IsCancellationRequested)
{
@@ -413,13 +413,13 @@ await bus.PublishAsync(new InboundMessage(
ArrivedAt: DateTimeOffset.UtcNow,
ModelOverride: job.Model,
ProviderOverride: job.Provider
- ), ct);
+ ), ct).ConfigureAwait(false);
var now = DateTimeOffset.UtcNow;
var newCount = job.RunCount + 1;
// Update in-memory state under lock
- await _jobsLock.WaitAsync(ct);
+ await _jobsLock.WaitAsync(ct).ConfigureAwait(false);
try
{
if (_jobs.TryGetValue(job.Id, out var current))
@@ -441,7 +441,7 @@ await bus.PublishAsync(new InboundMessage(
using var statsCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
try
{
- await store.UpdateRunStatsAsync(job.Id, now, newCount, statsCts.Token);
+ await store.UpdateRunStatsAsync(job.Id, now, newCount, statsCts.Token).ConfigureAwait(false);
}
catch (Exception ex)
{
@@ -454,7 +454,7 @@ await bus.PublishAsync(new InboundMessage(
try
{
using var atCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
- await _jobsLock.WaitAsync(atCts.Token);
+ await _jobsLock.WaitAsync(atCts.Token).ConfigureAwait(false);
CronJob? disabled;
try
{
@@ -467,7 +467,7 @@ await bus.PublishAsync(new InboundMessage(
if (disabled is not null)
{
- await store.UpsertAsync(disabled, atCts.Token);
+ await store.UpsertAsync(disabled, atCts.Token).ConfigureAwait(false);
}
}
catch (Exception ex)
diff --git a/src/clawsharp/Core/Services/FallbackChain.cs b/src/clawsharp/Core/Services/FallbackChain.cs
index d6b856b7..32a3addb 100644
--- a/src/clawsharp/Core/Services/FallbackChain.cs
+++ b/src/clawsharp/Core/Services/FallbackChain.cs
@@ -37,7 +37,7 @@ public async Task ExecuteAsync(
try
{
- var result = await action(name, provider, ct);
+ var result = await action(name, provider, ct).ConfigureAwait(false);
cooldowns.RecordSuccess(name);
return result;
}
@@ -110,7 +110,7 @@ public async IAsyncEnumerable ExecuteStreamAsync(
try
{
enumerator = provider.StreamAsync(effectiveRequest, ct).GetAsyncEnumerator(ct);
- hasFirst = await enumerator.MoveNextAsync();
+ hasFirst = await enumerator.MoveNextAsync().ConfigureAwait(false);
firstChunk = hasFirst ? enumerator.Current : null;
}
catch (Exception ex) when (ex is not OperationCanceledException)
@@ -118,7 +118,7 @@ public async IAsyncEnumerable ExecuteStreamAsync(
// Dispose the enumerator on error — it may hold an HTTP connection.
if (enumerator is not null)
{
- await enumerator.DisposeAsync();
+ await enumerator.DisposeAsync().ConfigureAwait(false);
}
var reason = ErrorClassifier.Classify(ex);
@@ -150,7 +150,7 @@ public async IAsyncEnumerable ExecuteStreamAsync(
// rather than propagated (which would mask the actual stream result).
try
{
- while (hasFirst && await enumerator.MoveNextAsync())
+ while (hasFirst && await enumerator.MoveNextAsync().ConfigureAwait(false))
{
yield return enumerator.Current;
}
@@ -159,7 +159,7 @@ public async IAsyncEnumerable ExecuteStreamAsync(
{
try
{
- await enumerator.DisposeAsync();
+ await enumerator.DisposeAsync().ConfigureAwait(false);
}
catch (Exception disposeEx)
{
diff --git a/src/clawsharp/Core/Services/HeartbeatService.cs b/src/clawsharp/Core/Services/HeartbeatService.cs
index f077a143..8387bd0d 100644
--- a/src/clawsharp/Core/Services/HeartbeatService.cs
+++ b/src/clawsharp/Core/Services/HeartbeatService.cs
@@ -56,7 +56,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Sleep 10 seconds then check if the cron expression matches the current minute.
// This mirrors the polling approach used by CronService.
- await Task.Delay(PollIntervalMs, stoppingToken);
+ await Task.Delay(PollIntervalMs, stoppingToken).ConfigureAwait(false);
// Heartbeat cron schedule is evaluated against the machine's local time
// (DateTimeOffset.Now), NOT UTC. This matches user expectations for schedules
@@ -77,7 +77,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
Volatile.Write(ref _lastFiredMinuteTicks, truncatedTicks);
- var prompt = await ReadPromptFileAsync(stoppingToken);
+ var prompt = await ReadPromptFileAsync(stoppingToken).ConfigureAwait(false);
LogHeartbeatFiring(_logger, _heartbeatConfig.Channel, prompt.Length);
await _bus.PublishAsync(new InboundMessage(
@@ -87,7 +87,7 @@ await _bus.PublishAsync(new InboundMessage(
Text: prompt,
ArrivedAt: DateTimeOffset.UtcNow,
IsHeartbeat: true
- ), stoppingToken);
+ ), stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
@@ -132,7 +132,7 @@ private async Task ReadPromptFileAsync(CancellationToken ct)
try
{
- var content = await File.ReadAllTextAsync(resolved, ct);
+ var content = await File.ReadAllTextAsync(resolved, ct).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(content))
{
return content.Trim();
diff --git a/src/clawsharp/Core/Services/LifecycleBackgroundService.cs b/src/clawsharp/Core/Services/LifecycleBackgroundService.cs
index 083c3036..d2b858bc 100644
--- a/src/clawsharp/Core/Services/LifecycleBackgroundService.cs
+++ b/src/clawsharp/Core/Services/LifecycleBackgroundService.cs
@@ -44,7 +44,7 @@ public virtual async Task StopAsync(CancellationToken cancellationToken)
try
{
- await _cts!.CancelAsync();
+ await _cts.CancelAsync().ConfigureAwait(false);
}
finally
{
diff --git a/src/clawsharp/Core/Sessions/SessionStore.cs b/src/clawsharp/Core/Sessions/SessionStore.cs
index e92d2488..f1e3d8d7 100644
--- a/src/clawsharp/Core/Sessions/SessionStore.cs
+++ b/src/clawsharp/Core/Sessions/SessionStore.cs
@@ -1,6 +1,7 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
+using Clawsharp.Core.Utilities;
using Microsoft.Extensions.Logging;
namespace Clawsharp.Core.Sessions;
@@ -19,9 +20,9 @@ public SessionStore(ILogger logger)
{
_logger = logger;
var root = Config.ConfigLoader.ExpandHome("~/.clawsharp");
- Directory.CreateDirectory(root);
+ FilePermissions.EnsureRestrictedDirectory(root);
_dir = Path.Combine(root, "sessions");
- Directory.CreateDirectory(_dir);
+ FilePermissions.EnsureRestrictedDirectory(_dir);
}
/// Test-only constructor with custom sessions directory.
@@ -43,7 +44,7 @@ public async Task LoadOrCreateAsync(string sessionId, CancellationToken
try
{
await using var stream = File.OpenRead(path);
- var session = await JsonSerializer.DeserializeAsync(stream, SessionJsonContext.Default.Session, ct);
+ var session = await JsonSerializer.DeserializeAsync(stream, SessionJsonContext.Default.Session, ct).ConfigureAwait(false);
return session ?? new Session { Id = sessionId };
}
catch (Exception ex) when (ex is JsonException or IOException)
@@ -61,11 +62,12 @@ public async Task SaveAsync(Session session, CancellationToken ct = default)
{
await using (var stream = File.Create(tmp))
{
- await JsonSerializer.SerializeAsync(stream, session, SessionJsonContext.Default.Session, ct);
- await stream.FlushAsync(ct);
+ await JsonSerializer.SerializeAsync(stream, session, SessionJsonContext.Default.Session, ct).ConfigureAwait(false);
+ await stream.FlushAsync(ct).ConfigureAwait(false);
}
File.Move(tmp, path, true);
+ FilePermissions.SetRestrictedFilePermissions(path);
}
catch
{
@@ -86,6 +88,8 @@ public async Task SaveAsync(Session session, CancellationToken ct = default)
/// Builds a safe filesystem path for the given session ID.
/// Uses for reversible, collision-free encoding.
/// Falls back to a truncated SHA-256 hash if the encoded name exceeds 200 characters.
+ /// The 16-character (8-byte) hash prefix has a ~2^32 collision threshold — acceptable
+ /// for a personal assistant but would need a longer prefix for multi-tenant deployments.
///
internal string SessionPath(string sessionId)
{
diff --git a/src/clawsharp/Core/Transcription/VoiceTranscriptionService.cs b/src/clawsharp/Core/Transcription/VoiceTranscriptionService.cs
index e70b21f4..5f5fcf2f 100644
--- a/src/clawsharp/Core/Transcription/VoiceTranscriptionService.cs
+++ b/src/clawsharp/Core/Transcription/VoiceTranscriptionService.cs
@@ -153,7 +153,7 @@ public VoiceTranscriptionService(
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey);
req.Content = form;
- using var resp = await _http!.SendAsync(req, ct).ConfigureAwait(false);
+ using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false);
if (!resp.IsSuccessStatusCode)
{
var errorBody = await ReadErrorBodyAsync(resp, ct).ConfigureAwait(false);
@@ -200,7 +200,7 @@ public VoiceTranscriptionService(
req.Headers.Add("Ocp-Apim-Subscription-Key", _apiKey);
req.Content = form;
- using var resp = await _http!.SendAsync(req, ct).ConfigureAwait(false);
+ using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false);
if (!resp.IsSuccessStatusCode)
{
var errorBody = await ReadErrorBodyAsync(resp, ct).ConfigureAwait(false);
@@ -302,12 +302,10 @@ public VoiceTranscriptionService(
},
};
- var bodyJson = JsonSerializer.Serialize(
- reqBody, VoiceTranscriptJsonContext.Default.GcpSpeechRequest);
- using var content = new StringContent(bodyJson, Encoding.UTF8, "application/json");
+ using var content = Utf8JsonContent.Create(reqBody, VoiceTranscriptJsonContext.Default.GcpSpeechRequest);
var url = $"{_gcpUrl}?key={Uri.EscapeDataString(_apiKey!)}";
- using var resp = await _http!.PostAsync(url, content, ct).ConfigureAwait(false);
+ using var resp = await _http.PostAsync(url, content, ct).ConfigureAwait(false);
if (!resp.IsSuccessStatusCode)
{
var errorBody = await ReadErrorBodyAsync(resp, ct).ConfigureAwait(false);
diff --git a/src/clawsharp/Core/Utilities/FilePermissions.cs b/src/clawsharp/Core/Utilities/FilePermissions.cs
new file mode 100644
index 00000000..74a90f24
--- /dev/null
+++ b/src/clawsharp/Core/Utilities/FilePermissions.cs
@@ -0,0 +1,34 @@
+namespace Clawsharp.Core.Utilities;
+
+///
+/// Enforces restrictive Unix file permissions (owner-only) on data directories and files.
+/// No-op on Windows where Unix file modes are not supported.
+///
+internal static class FilePermissions
+{
+ ///
+ /// Creates the directory (if needed) and restricts it to owner rwx (0700) on Unix.
+ ///
+ internal static void EnsureRestrictedDirectory(string path)
+ {
+ Directory.CreateDirectory(path);
+ if (!OperatingSystem.IsWindows())
+ {
+ File.SetUnixFileMode(path,
+ UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
+ }
+ }
+
+ ///
+ /// Restricts an existing file to owner rw (0600) on Unix.
+ /// No-op if the file does not exist or the OS is Windows.
+ ///
+ internal static void SetRestrictedFilePermissions(string path)
+ {
+ if (!OperatingSystem.IsWindows() && File.Exists(path))
+ {
+ File.SetUnixFileMode(path,
+ UnixFileMode.UserRead | UnixFileMode.UserWrite);
+ }
+ }
+}
diff --git a/src/clawsharp/Core/Utilities/JsonContent.cs b/src/clawsharp/Core/Utilities/JsonContent.cs
new file mode 100644
index 00000000..864ad1e8
--- /dev/null
+++ b/src/clawsharp/Core/Utilities/JsonContent.cs
@@ -0,0 +1,59 @@
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization.Metadata;
+
+namespace Clawsharp.Core.Utilities;
+
+///
+/// Creates from JSON data using UTF-8 bytes directly,
+/// avoiding the double-encoding overhead of
+/// (which accepts a UTF-16 string and re-encodes it to UTF-8).
+/// Named Utf8JsonContent to avoid collision with .
+///
+internal static class Utf8JsonContent
+{
+ ///
+ /// Serializes directly to UTF-8 bytes using the provided
+ /// source-generated , then wraps in .
+ ///
+ public static HttpContent Create(T value, JsonTypeInfo typeInfo)
+ {
+ var bytes = JsonSerializer.SerializeToUtf8Bytes(value, typeInfo);
+ return Wrap(bytes);
+ }
+
+ ///
+ /// Serializes directly to UTF-8 bytes using the provided
+ /// non-generic , then wraps in .
+ /// Useful for patterns where the type info is dynamically typed.
+ ///
+ public static HttpContent Create(object value, JsonTypeInfo typeInfo)
+ {
+ var bytes = JsonSerializer.SerializeToUtf8Bytes(value, typeInfo);
+ return Wrap(bytes);
+ }
+
+ ///
+ /// Wraps a pre-serialized JSON string as UTF-8 ,
+ /// avoiding the intermediate UTF-16 re-encoding that performs.
+ ///
+ public static HttpContent FromString(string json)
+ {
+ var bytes = Encoding.UTF8.GetBytes(json);
+ return Wrap(bytes);
+ }
+
+ ///
+ /// Wraps pre-serialized UTF-8 JSON bytes as .
+ /// Useful when the same bytes must be sent multiple times (e.g., retry after re-login).
+ ///
+ public static HttpContent FromUtf8Bytes(byte[] jsonBytes) => Wrap(jsonBytes);
+
+ private static ReadOnlyMemoryContent Wrap(byte[] bytes)
+ {
+ var content = new ReadOnlyMemoryContent(bytes);
+ content.Headers.ContentType = new MediaTypeHeaderValue("application/json") { CharSet = "utf-8" };
+ return content;
+ }
+}
diff --git a/src/clawsharp/Core/Utilities/JsonFileStore.cs b/src/clawsharp/Core/Utilities/JsonFileStore.cs
index 67e97bc4..974c4f47 100644
--- a/src/clawsharp/Core/Utilities/JsonFileStore.cs
+++ b/src/clawsharp/Core/Utilities/JsonFileStore.cs
@@ -8,7 +8,7 @@ namespace Clawsharp.Core.Utilities;
/// Designed for small configuration/state files (pairing codes, approved senders, etc.).
///
/// Thread safety: all operations acquire a before touching the file.
-/// Atomic writes: data is written to a .tmp file first, then moved into place via .
+/// Atomic writes: data is written to a .tmp file first, then moved into place via .
///
/// The type to serialize/deserialize. Must have a source-generated .
public sealed class JsonFileStore : IDisposable where T : class, new()
diff --git a/src/clawsharp/Cost/CostStorage.cs b/src/clawsharp/Cost/CostStorage.cs
index 8612f499..b7c95199 100644
--- a/src/clawsharp/Cost/CostStorage.cs
+++ b/src/clawsharp/Cost/CostStorage.cs
@@ -1,5 +1,6 @@
using System.Text.Json;
using Clawsharp.Config;
+using Clawsharp.Core.Utilities;
namespace Clawsharp.Cost;
@@ -25,7 +26,7 @@ public sealed class CostStorage
public CostStorage()
{
var dir = ConfigLoader.ExpandHome("~/.clawsharp");
- Directory.CreateDirectory(dir);
+ FilePermissions.EnsureRestrictedDirectory(dir);
_filePath = Path.Combine(dir, "costs.jsonl");
}
@@ -48,7 +49,7 @@ public async Task AppendAsync(CostRecord record, CancellationToken ct = default)
await _writeLock.WaitAsync(ct).ConfigureAwait(false);
try
{
- await File.AppendAllTextAsync(_filePath, json + "\n", ct).ConfigureAwait(false);
+ await File.AppendAllLinesAsync(_filePath, [json], ct).ConfigureAwait(false);
// Invalidate the cache — next ReadAllAsync will re-read the file
lock (_cacheLock)
@@ -67,6 +68,13 @@ public async Task AppendAsync(CostRecord record, CancellationToken ct = default)
/// Uses a simple in-memory cache that is invalidated when a new record is written
/// or when the file's last-write time changes (e.g., external edits).
///
+ ///
+ /// No lock is held across File.Exists, GetLastWriteTimeUtc, and ReadLinesAsync.
+ /// External file manipulation between these calls could yield stale or empty results.
+ /// This is acceptable because the file is in ~/.clawsharp/ under user control and no
+ /// external process is expected to modify it during operation. Write-side serialization
+ /// via _writeLock ensures internal consistency.
+ ///
public async Task> ReadAllAsync(CancellationToken ct = default)
{
if (!File.Exists(_filePath))
diff --git a/src/clawsharp/Cost/CostTracker.cs b/src/clawsharp/Cost/CostTracker.cs
index 83b4c52e..bf7aa6e5 100644
--- a/src/clawsharp/Cost/CostTracker.cs
+++ b/src/clawsharp/Cost/CostTracker.cs
@@ -21,6 +21,8 @@ public sealed partial class CostTracker(
// Global in-memory aggregation (backward compat)
private decimal _dailyTotal;
private decimal _monthlyTotal;
+ private decimal _dailySavings;
+ private decimal _monthlySavings;
// Per-scope aggregation via ConcurrentDictionary
// Scope key format: "global", "user:{name}", "dept:{name}"
@@ -41,6 +43,12 @@ public sealed partial class CostTracker(
/// after all exceed checks pass. Returns extended
/// with per-scope status via .
///
+ ///
+ /// The lock is released before the LLM call runs, creating a check-then-act window.
+ /// Concurrent requests can exceed limits by up to N * estimatedCost where N is concurrency depth.
+ /// This is an intentional trade-off: strict enforcement would serialize all concurrent requests.
+ /// Real-world overspend is bounded to fractions of a cent for typical usage patterns.
+ ///
public async Task CheckBudgetAsync(
decimal estimatedCost,
string? userId = null,
@@ -55,13 +63,28 @@ public async Task CheckBudgetAsync(
}
decimal dailySnapshot, monthlySnapshot;
- await _lock.WaitAsync(ct);
+ ScopeBudgetStatus? userStatus = null;
+ ScopeBudgetStatus? deptStatus = null;
+
+ await _lock.WaitAsync(ct).ConfigureAwait(false);
try
{
- await EnsureInitializedAsync(ct);
+ await EnsureInitializedAsync(ct).ConfigureAwait(false);
CheckDayMonthBoundary();
dailySnapshot = _dailyTotal;
monthlySnapshot = _monthlyTotal;
+
+ // Snapshot per-scope totals inside the lock to avoid TOCTOU between
+ // the global and per-scope reads (M-12).
+ if (userId is not null && userBudget is not null)
+ {
+ userStatus = EvaluateScope($"user:{userId}", userBudget, estimatedCost);
+ }
+
+ if (departmentId is not null && deptBudget is not null)
+ {
+ deptStatus = EvaluateScope($"dept:{departmentId}", deptBudget, estimatedCost);
+ }
}
finally
{
@@ -94,36 +117,26 @@ public async Task CheckBudgetAsync(
}
// --- Check per-user scope ---
- ScopeBudgetStatus? userStatus = null;
- if (userId is not null && userBudget is not null)
+ if (userStatus is { Status: BudgetStatus.Exceeded })
{
- userStatus = EvaluateScope($"user:{userId}", userBudget, estimatedCost);
- if (userStatus.Status == BudgetStatus.Exceeded)
- {
- return new BudgetCheckResult(
- BudgetStatus.Exceeded,
- $"User daily budget exceeded: ${userStatus.DailyUsed:F4} / ${userStatus.DailyLimit:F2}",
- dailySnapshot,
- monthlySnapshot,
- UserBudget: userStatus);
- }
+ return new BudgetCheckResult(
+ BudgetStatus.Exceeded,
+ $"User daily budget exceeded: ${userStatus.DailyUsed:F4} / ${userStatus.DailyLimit:F2}",
+ dailySnapshot,
+ monthlySnapshot,
+ UserBudget: userStatus);
}
// --- Check per-department scope ---
- ScopeBudgetStatus? deptStatus = null;
- if (departmentId is not null && deptBudget is not null)
+ if (deptStatus is { Status: BudgetStatus.Exceeded })
{
- deptStatus = EvaluateScope($"dept:{departmentId}", deptBudget, estimatedCost);
- if (deptStatus.Status == BudgetStatus.Exceeded)
- {
- return new BudgetCheckResult(
- BudgetStatus.Exceeded,
- $"{departmentId} department monthly budget exhausted (${deptStatus.MonthlyLimit:F2}). Contact your admin.",
- dailySnapshot,
- monthlySnapshot,
- UserBudget: userStatus,
- DepartmentBudget: deptStatus);
- }
+ return new BudgetCheckResult(
+ BudgetStatus.Exceeded,
+ $"{departmentId} department monthly budget exhausted (${deptStatus.MonthlyLimit:F2}). Contact your admin.",
+ dailySnapshot,
+ monthlySnapshot,
+ UserBudget: userStatus,
+ DepartmentBudget: deptStatus);
}
// --- Collect warnings from all scopes ---
@@ -277,11 +290,8 @@ public async Task RecordUsageAsync(
cost = reportedCost;
}
- var cacheSavings = 0.0m;
- if (savings > 0)
- {
- cacheSavings = savings;
- }
+ // Cache savings can be negative when Anthropic cache write premiums exceed read discounts.
+ var cacheSavings = savings;
var record = new CostRecord
{
@@ -299,14 +309,16 @@ public async Task RecordUsageAsync(
DepartmentId = departmentId,
};
- await storage.AppendAsync(record, ct);
+ await storage.AppendAsync(record, ct).ConfigureAwait(false);
- await _lock.WaitAsync(ct);
+ await _lock.WaitAsync(ct).ConfigureAwait(false);
try
{
CheckDayMonthBoundary();
_dailyTotal += cost;
_monthlyTotal += cost;
+ _dailySavings += cacheSavings;
+ _monthlySavings += cacheSavings;
}
finally
{
@@ -343,59 +355,54 @@ public async Task RecordUsageAsync(
}
/// Get current cost and cache-savings summary. Optionally filter by session.
+ ///
+ /// Daily, Monthly, DailySavings, and MonthlySavings are from the in-memory snapshot taken under lock.
+ /// When is provided, Session and SessionSavings are computed from a
+ /// disk scan that may include records written after the snapshot. Session totals may therefore
+ /// diverge from daily/monthly totals by the cost of one in-flight request. Budget enforcement
+ /// uses its own locking path and is unaffected.
+ ///
public async Task GetSummaryAsync(
string? sessionId = null,
CancellationToken ct = default)
{
- await _lock.WaitAsync(ct);
+ await _lock.WaitAsync(ct).ConfigureAwait(false);
decimal daily;
decimal monthly;
+ decimal dailySavingsSnapshot;
+ decimal monthlySavingsSnapshot;
try
{
- await EnsureInitializedAsync(ct);
+ await EnsureInitializedAsync(ct).ConfigureAwait(false);
CheckDayMonthBoundary();
daily = _dailyTotal;
monthly = _monthlyTotal;
+ dailySavingsSnapshot = _dailySavings;
+ monthlySavingsSnapshot = _monthlySavings;
}
finally
{
_lock.Release();
}
- // Savings and session totals are not tracked in memory -- scan disk for those.
+ // Session totals require a sessionId filter — scan disk only when requested.
var session = 0.0m;
- var dailySavings = 0.0m;
- var monthlySavings = 0.0m;
var sessionSavings = 0.0m;
- var records = await storage.ReadAllAsync(ct);
- var now = DateTimeOffset.UtcNow;
- var todayUtc = DateOnly.FromDateTime(now.UtcDateTime);
-
- foreach (var r in records)
+ if (sessionId is not null)
{
- var recordDate = DateOnly.FromDateTime(r.Timestamp.UtcDateTime);
-
- if (recordDate == todayUtc)
+ var records = await storage.ReadAllAsync(ct).ConfigureAwait(false);
+ foreach (var r in records)
{
- dailySavings += r.CacheSavingsUsd;
- }
-
- if (r.Timestamp.UtcDateTime.Year == now.UtcDateTime.Year &&
- r.Timestamp.UtcDateTime.Month == now.UtcDateTime.Month)
- {
- monthlySavings += r.CacheSavingsUsd;
- }
-
- if (sessionId is not null &&
- string.Equals(r.SessionId, sessionId, StringComparison.Ordinal))
- {
- session += r.CostUsd;
- sessionSavings += r.CacheSavingsUsd;
+ if (string.Equals(r.SessionId, sessionId, StringComparison.Ordinal))
+ {
+ session += r.CostUsd;
+ sessionSavings += r.CacheSavingsUsd;
+ }
}
}
- return new CostSummary(daily, monthly, session, dailySavings, monthlySavings, sessionSavings);
+ return new CostSummary(daily, monthly, session, dailySavingsSnapshot, monthlySavingsSnapshot, sessionSavings);
}
/// Detect day/month boundary crossings and reset in-memory aggregates.
@@ -407,12 +414,14 @@ private void CheckDayMonthBoundary()
if (todayUtc != _currentDay)
{
_dailyTotal = 0;
+ _dailySavings = 0;
_dailyTotals.Clear();
_currentDay = todayUtc;
if (now.UtcDateTime.Year != _currentYear || now.UtcDateTime.Month != _currentMonth)
{
_monthlyTotal = 0;
+ _monthlySavings = 0;
_monthlyTotals.Clear();
_currentMonth = now.UtcDateTime.Month;
_currentYear = now.UtcDateTime.Year;
@@ -428,7 +437,7 @@ private async Task EnsureInitializedAsync(CancellationToken ct)
return;
}
- var records = await storage.ReadAllAsync(ct);
+ var records = await storage.ReadAllAsync(ct).ConfigureAwait(false);
var now = DateTimeOffset.UtcNow;
var todayUtc = DateOnly.FromDateTime(now.UtcDateTime);
_currentDay = todayUtc;
@@ -445,6 +454,7 @@ private async Task EnsureInitializedAsync(CancellationToken ct)
if (isToday)
{
_dailyTotal += r.CostUsd;
+ _dailySavings += r.CacheSavingsUsd;
// Per-scope daily aggregation from JSONL
if (r.UserId is not null)
@@ -463,6 +473,7 @@ private async Task EnsureInitializedAsync(CancellationToken ct)
if (isThisMonth)
{
_monthlyTotal += r.CostUsd;
+ _monthlySavings += r.CacheSavingsUsd;
// Per-scope monthly aggregation from JSONL
if (r.UserId is not null)
diff --git a/src/clawsharp/Cost/DefaultPricing.cs b/src/clawsharp/Cost/DefaultPricing.cs
index 299d477d..8e4ab025 100644
--- a/src/clawsharp/Cost/DefaultPricing.cs
+++ b/src/clawsharp/Cost/DefaultPricing.cs
@@ -25,18 +25,53 @@ public static class DefaultPricing
["claude-3-haiku"] = (0.25m, 1.25m),
// OpenAI (prefixed)
- ["openai/gpt-4o"] = (5.00m, 15.00m),
+ ["openai/gpt-4o"] = (2.50m, 10.00m),
["openai/gpt-4o-mini"] = (0.15m, 0.60m),
- ["openai/o1-preview"] = (15.00m, 60.00m),
+ ["openai/gpt-4.1"] = (2.00m, 8.00m),
+ ["openai/gpt-4.1-mini"] = (0.40m, 1.60m),
+ ["openai/gpt-4.1-nano"] = (0.10m, 0.40m),
+ ["openai/gpt-5"] = (1.25m, 10.00m),
+ ["openai/gpt-5-mini"] = (0.25m, 2.00m),
+ ["openai/gpt-5-nano"] = (0.05m, 0.40m),
+ ["openai/gpt-5-pro"] = (15.00m, 120.00m),
+ ["openai/gpt-5.1"] = (1.25m, 10.00m),
+ ["openai/gpt-5.2"] = (1.75m, 14.00m),
+ ["openai/gpt-5.4"] = (2.50m, 15.00m),
+ ["openai/gpt-5.4-mini"] = (0.75m, 4.50m),
+ ["openai/gpt-5.4-nano"] = (0.20m, 1.25m),
+ ["openai/gpt-5.4-pro"] = (30.00m, 180.00m),
+ ["openai/o1"] = (15.00m, 60.00m),
+ ["openai/o1-mini"] = (1.10m, 4.40m),
+ ["openai/o1-pro"] = (150.00m, 600.00m),
+ ["openai/o3"] = (2.00m, 8.00m),
+ ["openai/o3-mini"] = (1.10m, 4.40m),
+ ["openai/o3-pro"] = (20.00m, 80.00m),
+ ["openai/o4-mini"] = (1.10m, 4.40m),
// OpenAI (bare)
- ["gpt-4o"] = (5.00m, 15.00m),
+ ["gpt-4o"] = (2.50m, 10.00m),
["gpt-4o-mini"] = (0.15m, 0.60m),
["gpt-4.1"] = (2.00m, 8.00m),
["gpt-4.1-mini"] = (0.40m, 1.60m),
- ["gpt-5.2"] = (5.00m, 15.00m),
- ["o1-preview"] = (15.00m, 60.00m),
+ ["gpt-4.1-nano"] = (0.10m, 0.40m),
+ ["gpt-5"] = (1.25m, 10.00m),
+ ["gpt-5-mini"] = (0.25m, 2.00m),
+ ["gpt-5-nano"] = (0.05m, 0.40m),
+ ["gpt-5-pro"] = (15.00m, 120.00m),
+ ["gpt-5.1"] = (1.25m, 10.00m),
+ ["gpt-5.2"] = (1.75m, 14.00m),
+ ["gpt-5.2-pro"] = (21.00m, 168.00m),
+ ["gpt-5.4"] = (2.50m, 15.00m),
+ ["gpt-5.4-mini"] = (0.75m, 4.50m),
+ ["gpt-5.4-nano"] = (0.20m, 1.25m),
+ ["gpt-5.4-pro"] = (30.00m, 180.00m),
+ ["o1"] = (15.00m, 60.00m),
+ ["o1-mini"] = (1.10m, 4.40m),
+ ["o1-pro"] = (150.00m, 600.00m),
+ ["o3"] = (2.00m, 8.00m),
["o3-mini"] = (1.10m, 4.40m),
+ ["o3-pro"] = (20.00m, 80.00m),
+ ["o4-mini"] = (1.10m, 4.40m),
// Google (prefixed)
["google/gemini-2.0-flash"] = (0.10m, 0.40m),
@@ -111,10 +146,10 @@ public static class DefaultPricing
["kimi-k2-thinking"] = (0.60m, 2.50m),
// MiniMax
- ["MiniMax-Text-01"] = (0.20m, 1.10m),
- ["MiniMax-M2"] = (0.255m, 1.00m),
- ["MiniMax-M2.1"] = (0.27m, 0.95m),
- ["MiniMax-M2.5"] = (0.295m, 1.20m),
+ ["minimax-text-01"] = (0.20m, 1.10m),
+ ["minimax-m2"] = (0.255m, 1.00m),
+ ["minimax-m2.1"] = (0.27m, 0.95m),
+ ["minimax-m2.5"] = (0.295m, 1.20m),
// VolcEngine / ByteDance Doubao
["doubao-1-5-pro-32k-250115"] = (0.11m, 0.28m),
@@ -130,7 +165,7 @@ public static class DefaultPricing
["Qwen/Qwen2.5-72B-Instruct"] = (0.40m, 1.20m),
["Qwen/Qwen2.5-7B-Instruct"] = (0.05m, 0.20m),
["moonshotai/Kimi-K2-Instruct"] = (0.58m, 2.29m),
- ["MiniMaxAI/MiniMax-M2.5"] = (0.30m, 1.20m),
+ ["minimaxai/minimax-m2.5"] = (0.30m, 1.20m),
["meta-llama/Meta-Llama-3.1-8B-Instruct"] = (0.06m, 0.06m),
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
@@ -153,7 +188,7 @@ public static (decimal InputPer1M, decimal OutputPer1M) GetPrice(string model)
/// Calculate the USD cost for a given model and token counts.
/// Returns 0.0 for unknown models.
///
- public static decimal CalculateCost(string model, int inputTokens, int outputTokens)
+ public static decimal CalculateCost(string model, long inputTokens, long outputTokens)
{
var (inputPer1M, outputPer1M) = GetPrice(model);
if (inputPer1M == 0 && outputPer1M == 0)
@@ -171,8 +206,8 @@ public static decimal CalculateCost(string model, int inputTokens, int outputTok
///
public static decimal CalculateCost(
string model,
- int inputTokens,
- int outputTokens,
+ long inputTokens,
+ long outputTokens,
IReadOnlyDictionary? overrides)
{
if (overrides is not null &&
diff --git a/src/clawsharp/Cron/JsonCronStore.cs b/src/clawsharp/Cron/JsonCronStore.cs
index b8016e0b..ff37aa35 100644
--- a/src/clawsharp/Cron/JsonCronStore.cs
+++ b/src/clawsharp/Cron/JsonCronStore.cs
@@ -19,10 +19,10 @@ public Task InitAsync(CancellationToken ct = default)
public async Task> LoadAllAsync(CancellationToken ct = default)
{
- await _lock.WaitAsync(ct);
+ await _lock.WaitAsync(ct).ConfigureAwait(false);
try
{
- return await ReadAsync(ct);
+ return await ReadAsync(ct).ConfigureAwait(false);
}
finally
{
@@ -32,10 +32,10 @@ public async Task> LoadAllAsync(CancellationToken ct = de
public async Task UpsertAsync(CronJob job, CancellationToken ct = default)
{
- await _lock.WaitAsync(ct);
+ await _lock.WaitAsync(ct).ConfigureAwait(false);
try
{
- var jobs = await ReadAsync(ct);
+ var jobs = await ReadAsync(ct).ConfigureAwait(false);
var idx = jobs.FindIndex(j => j.Id == job.Id);
if (idx >= 0)
{
@@ -46,7 +46,7 @@ public async Task UpsertAsync(CronJob job, CancellationToken ct = default)
jobs.Add(job);
}
- await WriteAsync(jobs, ct);
+ await WriteAsync(jobs, ct).ConfigureAwait(false);
}
finally
{
@@ -56,12 +56,12 @@ public async Task UpsertAsync(CronJob job, CancellationToken ct = default)
public async Task DeleteAsync(string id, CancellationToken ct = default)
{
- await _lock.WaitAsync(ct);
+ await _lock.WaitAsync(ct).ConfigureAwait(false);
try
{
- var jobs = await ReadAsync(ct);
+ var jobs = await ReadAsync(ct).ConfigureAwait(false);
jobs.RemoveAll(j => j.Id == id);
- await WriteAsync(jobs, ct);
+ await WriteAsync(jobs, ct).ConfigureAwait(false);
}
finally
{
@@ -71,16 +71,16 @@ public async Task DeleteAsync(string id, CancellationToken ct = default)
public async Task UpdateRunStatsAsync(string id, DateTimeOffset lastRunAt, int runCount, CancellationToken ct = default)
{
- await _lock.WaitAsync(ct);
+ await _lock.WaitAsync(ct).ConfigureAwait(false);
try
{
- var jobs = await ReadAsync(ct);
+ var jobs = await ReadAsync(ct).ConfigureAwait(false);
var job = jobs.Find(j => j.Id == id);
if (job is not null)
{
job.LastRunAt = lastRunAt;
job.RunCount = runCount;
- await WriteAsync(jobs, ct);
+ await WriteAsync(jobs, ct).ConfigureAwait(false);
}
}
finally
@@ -98,7 +98,7 @@ private async Task> ReadAsync(CancellationToken ct)
try
{
- var json = await File.ReadAllTextAsync(_filePath, ct);
+ var json = await File.ReadAllTextAsync(_filePath, ct).ConfigureAwait(false);
return JsonSerializer.Deserialize(json, CronJsonContext.WithConverters.ListCronJob) ?? [];
}
catch (Exception ex)
diff --git a/src/clawsharp/Cron/MssqlCronStore.cs b/src/clawsharp/Cron/MssqlCronStore.cs
index 91b8cbda..a0bdd4b7 100644
--- a/src/clawsharp/Cron/MssqlCronStore.cs
+++ b/src/clawsharp/Cron/MssqlCronStore.cs
@@ -7,7 +7,7 @@ public sealed class MssqlCronStore(string connectionString) : ICronStore
public async Task InitAsync(CancellationToken ct = default)
{
await using var conn = new SqlConnection(connectionString);
- await conn.OpenAsync(ct);
+ await conn.OpenAsync(ct).ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'cron_jobs')
@@ -29,7 +29,7 @@ model NVARCHAR(255),
provider NVARCHAR(255)
);
""";
- await cmd.ExecuteNonQueryAsync(ct);
+ await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
// Migrate existing tables that lack model/provider columns.
await using var alter = conn.CreateCommand();
@@ -39,19 +39,19 @@ IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('cron_jobs'
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('cron_jobs') AND name = 'provider')
ALTER TABLE cron_jobs ADD provider NVARCHAR(255);
""";
- await alter.ExecuteNonQueryAsync(ct);
+ await alter.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
public async Task> LoadAllAsync(CancellationToken ct = default)
{
await using var conn = new SqlConnection(connectionString);
- await conn.OpenAsync(ct);
+ await conn.OpenAsync(ct).ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText =
"SELECT id,name,schedule_kind,schedule_expr,tz,channel,message,sender_id,enabled,created_at,last_run_at,run_count,source,model,provider FROM cron_jobs";
var jobs = new List();
- await using var reader = await cmd.ExecuteReaderAsync(ct);
- while (await reader.ReadAsync(ct))
+ await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
+ while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
jobs.Add(new CronJob
{
@@ -79,7 +79,7 @@ public async Task> LoadAllAsync(CancellationToken ct = de
public async Task UpsertAsync(CronJob job, CancellationToken ct = default)
{
await using var conn = new SqlConnection(connectionString);
- await conn.OpenAsync(ct);
+ await conn.OpenAsync(ct).ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
MERGE cron_jobs AS target
@@ -111,28 +111,28 @@ WHEN NOT MATCHED THEN INSERT (id,name,schedule_kind,schedule_expr,tz,channel,mes
cmd.Parameters.AddWithValue("@src", job.Source.Value);
cmd.Parameters.AddWithValue("@model", (object?)job.Model ?? DBNull.Value);
cmd.Parameters.AddWithValue("@provider", (object?)job.Provider ?? DBNull.Value);
- await cmd.ExecuteNonQueryAsync(ct);
+ await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
public async Task DeleteAsync(string id, CancellationToken ct = default)
{
await using var conn = new SqlConnection(connectionString);
- await conn.OpenAsync(ct);
+ await conn.OpenAsync(ct).ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM cron_jobs WHERE id = @id";
cmd.Parameters.AddWithValue("@id", id);
- await cmd.ExecuteNonQueryAsync(ct);
+ await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
public async Task UpdateRunStatsAsync(string id, DateTimeOffset lastRunAt, int runCount, CancellationToken ct = default)
{
await using var conn = new SqlConnection(connectionString);
- await conn.OpenAsync(ct);
+ await conn.OpenAsync(ct).ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "UPDATE cron_jobs SET last_run_at=@lra, run_count=@rc WHERE id=@id";
cmd.Parameters.AddWithValue("@lra", lastRunAt.ToString("O"));
cmd.Parameters.AddWithValue("@rc", runCount);
cmd.Parameters.AddWithValue("@id", id);
- await cmd.ExecuteNonQueryAsync(ct);
+ await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
}
\ No newline at end of file
diff --git a/src/clawsharp/Cron/PostgresCronStore.cs b/src/clawsharp/Cron/PostgresCronStore.cs
index a1d08777..4e10f356 100644
--- a/src/clawsharp/Cron/PostgresCronStore.cs
+++ b/src/clawsharp/Cron/PostgresCronStore.cs
@@ -7,7 +7,7 @@ public sealed class PostgresCronStore(string connectionString) : ICronStore
public async Task InitAsync(CancellationToken ct = default)
{
await using var conn = new NpgsqlConnection(connectionString);
- await conn.OpenAsync(ct);
+ await conn.OpenAsync(ct).ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
CREATE TABLE IF NOT EXISTS cron_jobs (
@@ -28,7 +28,7 @@ CREATE TABLE IF NOT EXISTS cron_jobs (
provider TEXT
);
""";
- await cmd.ExecuteNonQueryAsync(ct);
+ await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
// Migrate existing tables that lack model/provider columns.
await using var alter = conn.CreateCommand();
@@ -36,19 +36,19 @@ provider TEXT
ALTER TABLE cron_jobs ADD COLUMN IF NOT EXISTS model TEXT;
ALTER TABLE cron_jobs ADD COLUMN IF NOT EXISTS provider TEXT;
""";
- await alter.ExecuteNonQueryAsync(ct);
+ await alter.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
public async Task> LoadAllAsync(CancellationToken ct = default)
{
await using var conn = new NpgsqlConnection(connectionString);
- await conn.OpenAsync(ct);
+ await conn.OpenAsync(ct).ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText =
"SELECT id,name,schedule_kind,schedule_expr,tz,channel,message,sender_id,enabled,created_at,last_run_at,run_count,source,model,provider FROM cron_jobs";
var jobs = new List();
- await using var reader = await cmd.ExecuteReaderAsync(ct);
- while (await reader.ReadAsync(ct))
+ await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
+ while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
jobs.Add(new CronJob
{
@@ -76,7 +76,7 @@ public async Task> LoadAllAsync(CancellationToken ct = de
public async Task UpsertAsync(CronJob job, CancellationToken ct = default)
{
await using var conn = new NpgsqlConnection(connectionString);
- await conn.OpenAsync(ct);
+ await conn.OpenAsync(ct).ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT INTO cron_jobs (id,name,schedule_kind,schedule_expr,tz,channel,message,sender_id,enabled,created_at,last_run_at,run_count,source,model,provider)
@@ -104,28 +104,28 @@ ON CONFLICT(id) DO UPDATE SET
cmd.Parameters.AddWithValue("@src", job.Source.Value);
cmd.Parameters.AddWithValue("@model", (object?)job.Model ?? DBNull.Value);
cmd.Parameters.AddWithValue("@provider", (object?)job.Provider ?? DBNull.Value);
- await cmd.ExecuteNonQueryAsync(ct);
+ await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
public async Task DeleteAsync(string id, CancellationToken ct = default)
{
await using var conn = new NpgsqlConnection(connectionString);
- await conn.OpenAsync(ct);
+ await conn.OpenAsync(ct).ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM cron_jobs WHERE id = @id";
cmd.Parameters.AddWithValue("@id", id);
- await cmd.ExecuteNonQueryAsync(ct);
+ await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
public async Task UpdateRunStatsAsync(string id, DateTimeOffset lastRunAt, int runCount, CancellationToken ct = default)
{
await using var conn = new NpgsqlConnection(connectionString);
- await conn.OpenAsync(ct);
+ await conn.OpenAsync(ct).ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "UPDATE cron_jobs SET last_run_at=@lra, run_count=@rc WHERE id=@id";
cmd.Parameters.AddWithValue("@lra", lastRunAt.ToString("O"));
cmd.Parameters.AddWithValue("@rc", runCount);
cmd.Parameters.AddWithValue("@id", id);
- await cmd.ExecuteNonQueryAsync(ct);
+ await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
}
\ No newline at end of file
diff --git a/src/clawsharp/Cron/SqliteCronStore.cs b/src/clawsharp/Cron/SqliteCronStore.cs
index c2a50cad..6e57cc81 100644
--- a/src/clawsharp/Cron/SqliteCronStore.cs
+++ b/src/clawsharp/Cron/SqliteCronStore.cs
@@ -17,11 +17,11 @@ public sealed class SqliteCronStore(string dbPath) : ICronStore
public async Task InitAsync(CancellationToken ct = default)
{
- await _lock.WaitAsync(ct);
+ await _lock.WaitAsync(ct).ConfigureAwait(false);
try
{
await using var conn = new SqliteConnection(_connectionString);
- await conn.OpenAsync(ct);
+ await conn.OpenAsync(ct).ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
CREATE TABLE IF NOT EXISTS cron_jobs (
@@ -42,7 +42,7 @@ CREATE TABLE IF NOT EXISTS cron_jobs (
provider TEXT
);
""";
- await cmd.ExecuteNonQueryAsync(ct);
+ await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
// Migrate existing tables that lack model/provider columns.
await using var alter = conn.CreateCommand();
@@ -51,7 +51,7 @@ provider TEXT
""";
try
{
- await alter.ExecuteNonQueryAsync(ct);
+ await alter.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
catch (SqliteException)
{
@@ -64,7 +64,7 @@ provider TEXT
""";
try
{
- await alter2.ExecuteNonQueryAsync(ct);
+ await alter2.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
catch (SqliteException)
{
@@ -79,17 +79,17 @@ provider TEXT
public async Task> LoadAllAsync(CancellationToken ct = default)
{
- await _lock.WaitAsync(ct);
+ await _lock.WaitAsync(ct).ConfigureAwait(false);
try
{
await using var conn = new SqliteConnection(_connectionString);
- await conn.OpenAsync(ct);
+ await conn.OpenAsync(ct).ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText =
"SELECT id,name,schedule_kind,schedule_expr,tz,channel,message,sender_id,enabled,created_at,last_run_at,run_count,source,model,provider FROM cron_jobs";
var jobs = new List();
- await using var reader = await cmd.ExecuteReaderAsync(ct);
- while (await reader.ReadAsync(ct))
+ await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
+ while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
jobs.Add(MapRow(reader));
}
@@ -104,11 +104,11 @@ public async Task> LoadAllAsync(CancellationToken ct = de
public async Task UpsertAsync(CronJob job, CancellationToken ct = default)
{
- await _lock.WaitAsync(ct);
+ await _lock.WaitAsync(ct).ConfigureAwait(false);
try
{
await using var conn = new SqliteConnection(_connectionString);
- await conn.OpenAsync(ct);
+ await conn.OpenAsync(ct).ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT INTO cron_jobs (id,name,schedule_kind,schedule_expr,tz,channel,message,sender_id,enabled,created_at,last_run_at,run_count,source,model,provider)
@@ -122,7 +122,7 @@ ON CONFLICT(id) DO UPDATE SET
source=excluded.source, model=excluded.model, provider=excluded.provider;
""";
BindParams(cmd, job);
- await cmd.ExecuteNonQueryAsync(ct);
+ await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
finally
{
@@ -132,15 +132,15 @@ ON CONFLICT(id) DO UPDATE SET
public async Task DeleteAsync(string id, CancellationToken ct = default)
{
- await _lock.WaitAsync(ct);
+ await _lock.WaitAsync(ct).ConfigureAwait(false);
try
{
await using var conn = new SqliteConnection(_connectionString);
- await conn.OpenAsync(ct);
+ await conn.OpenAsync(ct).ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM cron_jobs WHERE id = @id";
cmd.Parameters.AddWithValue("@id", id);
- await cmd.ExecuteNonQueryAsync(ct);
+ await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
finally
{
@@ -150,17 +150,17 @@ public async Task DeleteAsync(string id, CancellationToken ct = default)
public async Task UpdateRunStatsAsync(string id, DateTimeOffset lastRunAt, int runCount, CancellationToken ct = default)
{
- await _lock.WaitAsync(ct);
+ await _lock.WaitAsync(ct).ConfigureAwait(false);
try
{
await using var conn = new SqliteConnection(_connectionString);
- await conn.OpenAsync(ct);
+ await conn.OpenAsync(ct).ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "UPDATE cron_jobs SET last_run_at=@lra, run_count=@rc WHERE id=@id";
cmd.Parameters.AddWithValue("@lra", lastRunAt.ToString("O"));
cmd.Parameters.AddWithValue("@rc", runCount);
cmd.Parameters.AddWithValue("@id", id);
- await cmd.ExecuteNonQueryAsync(ct);
+ await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
finally
{
diff --git a/src/clawsharp/Features/Behaviors/AuthorizationBehavior.cs b/src/clawsharp/Features/Behaviors/AuthorizationBehavior.cs
index dd184247..d335a6c0 100644
--- a/src/clawsharp/Features/Behaviors/AuthorizationBehavior.cs
+++ b/src/clawsharp/Features/Behaviors/AuthorizationBehavior.cs
@@ -1,6 +1,5 @@
using Clawsharp.Config;
using Immediate.Handlers.Shared;
-using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Clawsharp.Features.Behaviors;
@@ -10,9 +9,8 @@ namespace Clawsharp.Features.Behaviors;
/// Provides fast-path bypass for handlers that do not require authorization (D-18),
/// and serves as the gate + context + audit hook for the policy engine (D-17).
///
-public sealed partial class AuthorizationBehavior(
- IOptions appConfig,
- ILogger> logger
+public sealed class AuthorizationBehavior(
+ IOptions appConfig
) : Behavior
{
public override async ValueTask HandleAsync(
@@ -20,11 +18,11 @@ public override async ValueTask HandleAsync(
{
// Fast-path: no org config = no authorization needed (backward compat)
if (appConfig.Value.Organization is null)
- return await Next(request, cancellationToken);
+ return await Next(request, cancellationToken).ConfigureAwait(false);
// Fast-path: skip internal handlers that don't need auth (D-18)
if (!RequiresAuthorization(request))
- return await Next(request, cancellationToken);
+ return await Next(request, cancellationToken).ConfigureAwait(false);
// D-19: Context propagation + gates happen here.
// Phase 3 establishes the behavior in the pipeline.
@@ -32,7 +30,7 @@ public override async ValueTask