clawdforge/clients/csharp/examples/Basic/Program.cs
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

80 lines
2.4 KiB
C#

using System.Text.Json;
using Clawdforge;
using Clawdforge.Exceptions;
using Clawdforge.Models;
// Quickstart for the clawdforge C# SDK.
//
// Set environment vars:
// CLAWDFORGE_URL=http://192.168.0.5:8800
// CLAWDFORGE_TOKEN=cf_<your-app-token>
var baseUrl = Environment.GetEnvironmentVariable("CLAWDFORGE_URL")
?? "http://localhost:8800";
var token = Environment.GetEnvironmentVariable("CLAWDFORGE_TOKEN")
?? throw new InvalidOperationException("CLAWDFORGE_TOKEN env var is required");
using var client = new ForgeClient(new ForgeOptions
{
BaseUrl = baseUrl,
Token = token,
});
// 120s ceiling for the whole demo via cancellation.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120));
var ct = cts.Token;
try
{
var h = await client.HealthzAsync(ct);
Console.WriteLine($"clawdforge ok={h.Ok} claude_present={h.ClaudePresent} version={h.ClaudeVersion}");
var res = await client.RunAsync(new RunRequest
{
Prompt = "Reply with JSON: {\"hello\": \"world\"}",
Model = "sonnet",
TimeoutSecs = 60,
}, ct);
Console.WriteLine($"duration: {res.DurationMs}ms stop_reason: {res.StopReason}");
// res.Result is a JsonElement — caller narrows by ValueKind.
if (res.Result.ValueKind == JsonValueKind.Object
&& res.Result.TryGetProperty("hello", out var hello))
{
Console.WriteLine($"hello: {hello.GetString()}");
}
else
{
Console.WriteLine($"raw result: {res.AsText()}");
}
// Optional file-upload demo: only runs if a path is in argv[0].
if (args.Length > 0 && File.Exists(args[0]))
{
var ft = await client.UploadFileAsync(args[0], ttlSecs: 3600, ct);
Console.WriteLine($"uploaded: token={ft.Token} size={ft.Size}");
var withFile = await client.RunAsync(new RunRequest
{
Prompt = "Describe the attached file.",
Files = new[] { ft.Token },
}, ct);
Console.WriteLine($"file run: {withFile.AsText()}");
}
}
catch (ForgeAuthException ex)
{
Console.Error.WriteLine($"auth failed: {ex.StatusCode}: {ex.Body}");
Environment.Exit(2);
}
catch (ForgeApiException ex)
{
Console.Error.WriteLine($"api error {ex.StatusCode}: {ex.Body}");
Environment.Exit(3);
}
catch (ForgeTransportException ex)
{
Console.Error.WriteLine($"transport: {ex.Message}");
Environment.Exit(4);
}