clawdforge/clients/csharp
Kayos a507ed2a00 clients/csharp: apply audit findings — JSON depth caps + stream lifecycle (09aca58 → new)
MEDIUM:
- M1: JsonSerializerOptions.MaxDepth = 32 on the consolidated
  JsonDefaults.Options (referenced from both ForgeClient and
  RunResult.AsJson<T>) so the result payload's arbitrary upstream JSON
  cannot stack-walk the runtime.
- M2: JsonDocumentOptions.MaxDepth = 32 in SummarizeBody for parsing
  error-body summaries — defensive belt alongside the existing 8 MiB
  body cap.
- M3: UploadStreamAsync doc updated to match reality — the input stream
  IS disposed when the request completes (matches HttpClient /
  MultipartFormDataContent / StreamContent convention). Old doc was
  incorrect; chose doc-update over a non-disposing wrapper to stay
  closest to standard .NET stream semantics.

LOW:
- L2: RunResult.AsJson<T>() now guards JsonValueKind.Undefined and
  returns default(T) instead of throwing InvalidOperationException
  (e.g. when RunResult is constructed without a server payload).
- L4: IsNullOrWhiteSpace consistent across RunRequest.Prompt,
  CreateTokenRequest.Name, RevokeTokenAsync.name, UploadFileAsync.path,
  UploadStreamAsync.fileName (was IsNullOrEmpty letting space through).

Nit polish:
- BaseUrl cached in ctor instead of rebuilt per access.
- JsonDefaults moved to its own file (Models/JsonDefaults.cs) and is
  now the single source of truth for serializer options across the
  client.
- examples/Basic/Program.cs comment fixed: '60s' → '120s' to match
  TimeSpan.FromSeconds(120).

README:
- HTTPS / WireGuard recommendation in the Notes section — SDK does not
  enforce HTTPS, callers off-LAN should tunnel.
- .NET 8.0.10+ runtime recommendation with cref to CVE-2024-30105 and
  CVE-2024-43485 (SDK does not exercise the affected code paths;
  belt-and-suspenders).
- UploadStream section reflects the corrected disposal contract.

Tests (12 → 19, all passing):
- JsonOpts_MaxDepth_RejectsDeeplyNested — 200-deep result rejected via
  ForgeTransportException wrapping JsonException, no stack overflow.
- SummarizeBody_DeeplyNestedHandled — 200-deep error body still
  produces ForgeAuthException with raw body intact; summary parse
  fails closed without crashing.
- UploadStreamAsync_DisposesCallerStream — DisposeObservingStream
  helper verifies the contract change.
- AsJson_OnUndefinedResult_DefaultReturned — reference + value type.
- RunRequest_PromptWithOnlyWhitespace_Rejected.
- CreateToken_NameWithOnlyWhitespace_Rejected.
- BaseUrl_Cached_ReusesString — Assert.Same identity check.

Build: dotnet build -c Release -m:1 clean (0 warnings, 0 errors).
Tests: dotnet test -c Release -m:1 → 19 passed, 0 failed.
Pack:  dotnet pack -c Release -o dist -m:1 clean.
Vulns: dotnet list package --vulnerable --include-transitive → 0.

Audit: memory/clawdforge-audits/csharp-09aca58.md
2026-04-28 23:22:58 -07:00
..
examples/Basic clients/csharp: apply audit findings — JSON depth caps + stream lifecycle (09aca58 → new) 2026-04-28 23:22:58 -07:00
src/Clawdforge clients/csharp: apply audit findings — JSON depth caps + stream lifecycle (09aca58 → new) 2026-04-28 23:22:58 -07:00
tests/Clawdforge.Tests clients/csharp: apply audit findings — JSON depth caps + stream lifecycle (09aca58 → new) 2026-04-28 23:22:58 -07:00
.gitignore clients/csharp: initial C# SDK for clawdforge 2026-04-28 22:53:09 -07:00
Clawdforge.sln clients/csharp: initial C# SDK for clawdforge 2026-04-28 22:53:09 -07:00
README.md clients/csharp: apply audit findings — JSON depth caps + stream lifecycle (09aca58 → new) 2026-04-28 23:22:58 -07:00

clawdforge C# / .NET SDK

C# / .NET 8 SDK for the LAN-only clawdforge HTTP service — a thin REST wrapper around claude -p subprocess invocations.

  • Target framework: net8.0 (LTS).
  • HTTP: System.Net.Http.HttpClientIHttpClientFactory friendly.
  • JSON: System.Text.Json (no Newtonsoft).
  • Async: every I/O method returns Task<T> and accepts a CancellationToken. No sync wrappers.
  • Nullable reference types: enabled. Warnings as errors.
  • License: MIT.

Install

The package isn't published to nuget.org (LAN-only service). You have two local install paths.

Option A — local NuGet feed via dotnet pack

From this directory:

dotnet pack src/Clawdforge/Clawdforge.csproj -c Release -o ./dist

That drops Clawdforge.0.1.0.nupkg (and .snupkg symbols) under ./dist/. Add it as a source on the consumer machine:

dotnet nuget add source /path/to/clawdforge/clients/csharp/dist \
  --name clawdforge-local
dotnet add package Clawdforge --version 0.1.0

Option B — project reference

For sibling checkouts inside the same monorepo:

<ItemGroup>
  <ProjectReference Include="../../path/to/clawdforge/clients/csharp/src/Clawdforge/Clawdforge.csproj" />
</ItemGroup>

Quickstart

using System.Text.Json;
using Clawdforge;
using Clawdforge.Models;

using var client = new ForgeClient(new ForgeOptions {
    BaseUrl = "http://192.168.0.5:8800",
    Token   = Environment.GetEnvironmentVariable("CLAWDFORGE_TOKEN"),
});

// 60s wall-clock cap via cancellation, NOT HttpClient.Timeout.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60));

var h = await client.HealthzAsync(cts.Token);
Console.WriteLine($"claude: {h.ClaudeVersion}");

var res = await client.RunAsync(new RunRequest {
    Prompt      = "Reply with JSON: {\"hello\": \"world\"}",
    Model       = "sonnet",
    TimeoutSecs = 60,
}, cts.Token);

Console.WriteLine($"duration: {res.DurationMs}ms");

// res.Result is JsonElement — inspect ValueKind, then narrow.
if (res.Result.ValueKind == JsonValueKind.Object) {
    Console.WriteLine(res.Result.GetProperty("hello").GetString());
}

DI / IHttpClientFactory

Inject an external HttpClient so it shares the host's connection-pooling, retry, and proxy configuration:

services.AddHttpClient("clawdforge", c =>
{
    c.BaseAddress = new Uri("http://192.168.0.5:8800");
    c.Timeout = Timeout.InfiniteTimeSpan; // see "Timeouts" below
});

services.AddTransient<ForgeClient>(sp =>
{
    var http = sp.GetRequiredService<IHttpClientFactory>().CreateClient("clawdforge");
    return new ForgeClient(new ForgeOptions {
        BaseUrl = "http://192.168.0.5:8800",
        Token   = Environment.GetEnvironmentVariable("CLAWDFORGE_TOKEN"),
    }, http);
});

When you pass an external HttpClient, ForgeClient does NOT dispose it.

API surface

The SDK mirrors the FastAPI surface in clawdforge/server.py 1:1.

new ForgeClient(ForgeOptions, HttpClient? = null)

ForgeOptions:

Property Type Notes
BaseUrl string Required. Trailing slash is trimmed.
Token string? Bearer token. Optional for /healthz.
HttpTimeout TimeSpan? Wall-clock cap on the SDK-owned HttpClient. Default: Infinite.

HealthzAsync(CancellationToken)

GET /healthzHealthStatus { Ok, ClaudePresent, ClaudeVersion }. Bearer token optional; the server only enforces the global IP allowlist on this endpoint.

RunAsync(RunRequest, CancellationToken)

POST /runRunResult. The Result field is a JsonElement because claude may return parsed JSON or plain text.

var res = await client.RunAsync(new RunRequest {
    Prompt      = "Sterilize: 'about 2 cups cooked white rice'",
    Model       = "sonnet",
    System      = "Reply ONLY with valid JSON.",
    Files       = new[] { "ff_abc123" },
    TimeoutSecs = 30,
});

RunResult helpers:

// Deserialize into a typed shape:
public record Ingredient(int Qty, string Unit, string Food);
var ing = res.AsJson<Ingredient>();

// Or pull a string regardless of underlying JSON kind:
string? text = res.AsText();

UploadFileAsync(string path, int ttlSecs = 0, CancellationToken)

POST /files — multipart upload from disk. Streams via StreamContent(FileStream); the file is not buffered into memory. ttlSecs == 0 uses the server default of 3600 (valid range 60..86400).

var ft = await client.UploadFileAsync("./recipe.png", ttlSecs: 3600);

await client.RunAsync(new RunRequest {
    Prompt = "extract recipe data",
    Files  = new[] { ft.Token },
});

UploadStreamAsync(Stream, string fileName, int ttlSecs = 0, CancellationToken)

Same as UploadFileAsync but takes any Stream, useful for in-memory blobs or piped data. The stream is disposed by the SDK once the request completes — this matches the standard HttpClient / MultipartFormDataContent / StreamContent chain (they all call Dispose() through to the underlying stream). If the caller needs to retain ownership of the stream, wrap it in a non-disposing adapter before passing it in.

Admin (require admin bootstrap token)

using var admin = new ForgeClient(new ForgeOptions {
    BaseUrl = "http://192.168.0.5:8800",
    Token   = Environment.GetEnvironmentVariable("CLAWDFORGE_ADMIN_TOKEN"),
});

var t = await admin.CreateTokenAsync(new CreateTokenRequest {
    Name    = "petalparse",
    IpCidrs = new[] { "172.24.0.0/16" },
});
// t.Token is the plaintext — store it now, you can't see it again.

IReadOnlyList<AppToken> list = await admin.ListTokensAsync();

await admin.RevokeTokenAsync("petalparse");

Field naming — PascalCase ↔ snake_case

The clawdforge wire is snake_case (Python / Pydantic). The SDK uses PascalCase property names with [JsonPropertyName("...")] annotations to do the translation:

C# property Wire field
RunRequest.TimeoutSecs timeout_secs
RunResult.DurationMs duration_ms
RunResult.StopReason stop_reason
FileToken.Token file_token
FileToken.TtlSecs ttl_secs
AppToken.IpCidrs ip_cidrs
HealthStatus.ClaudePresent claude_present
HealthStatus.ClaudeVersion claude_version

null properties are omitted on the wire (configured at the serializer level), so optional fields behave correctly without manual checks.

Error handling

Three exception types, all derived from ForgeException:

Exception When
ForgeAuthException HTTP 401, 403 (subclass of ForgeApiException)
ForgeApiException Other HTTP >= 400 (incl. 502 /run failures)
ForgeTransportException DNS / connect / TLS / decode failures

Cancellation propagates as OperationCanceledException directly so callers can distinguish caller-cancellation from network failures.

try {
    var res = await client.RunAsync(req, ct);
}
catch (ForgeAuthException ex) {
    // 401 / 403 — bad bearer or IP not allowlisted.
}
catch (ForgeApiException ex) when (ex.StatusCode == 502) {
    // /run failure — clawdforge took the request but `claude` failed.
    // ex.Body has {"ok":false,"error":"...","stderr":"...","duration_ms":..., "stop_reason":"..."}
}
catch (ForgeApiException ex) {
    // Any other 4xx / 5xx.
}
catch (ForgeTransportException ex) {
    // DNS / connect refused / TLS / EOF / JSON decode.
}
catch (OperationCanceledException) {
    // Caller cancelled.
}

Timeouts

The SDK does NOT set a wall-clock timeout on its HttpClient by default — long claude runs (curate jobs, big recipe corpora) can legitimately take multiple minutes. Set per-call timeouts via cancellation:

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSecs + 30));
var res = await client.RunAsync(new RunRequest { Prompt = "...", TimeoutSecs = timeoutSecs }, cts.Token);

The +30s margin over TimeoutSecs mirrors the upstream Python Forge client so the HTTP call doesn't bail while clawdforge is still working.

Build / test / pack

dotnet build -c Release
dotnet test -c Release
dotnet pack -c Release -o ./dist

The example app under examples/Basic/:

export CLAWDFORGE_URL=http://192.168.0.5:8800
export CLAWDFORGE_TOKEN=cf_...
dotnet run --project examples/Basic

Notes

  • The CLI wrapped server-side is @anthropic-ai/claude-code (not the Python anthropic SDK).
  • RunRequest.TimeoutSecs is omitted when null; server clamps to 5..600.
  • 502 from /run is just a ForgeApiException with StatusCode = 502. The /run failure body is preserved verbatim in ex.Body; deserialize with JsonSerializer if you want structured access.
  • HealthzAsync does not require a bearer token. The SDK still sends it if present — the server ignores it.
  • File uploads stream via StreamContent(FileStream) — the file body is not buffered into memory.
  • Transport security: the SDK does not enforce HTTPS — BaseUrl may be plain http:// (clawdforge is a LAN-only service in our deployment). For any non-localhost / non-LAN target, use HTTPS or tunnel the connection through WireGuard / a VPN. Bearer tokens travel in Authorization headers; cleartext over an untrusted hop is a token leak.
  • Runtime: .NET 8.0.10 or later is recommended. Two BCL CVEs (CVE-2024-30105, CVE-2024-43485) cover JSON DoS paths the SDK does not currently exercise (DeserializeAsyncEnumerable and JsonExtensionData), but pinning to a patched runtime is cheap belt-and-suspenders insurance.