Skip to content

feat(csharp): add opt-in appInfo client option appended to User-Agent - #17303

Merged
cadesark merged 1 commit into
mainfrom
cade/csharp-user-agent-app-info
Aug 3, 2026
Merged

feat(csharp): add opt-in appInfo client option appended to User-Agent#17303
cadesark merged 1 commit into
mainfrom
cade/csharp-user-agent-app-info

Conversation

@cadesark

@cadesark cadesark commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Ports the opt-in appInfo User-Agent feature (TS reference PR #17290) to the C# SDK generator. Adds a new SDK generator config allow-user-agent-app-info (kebab-case; settings getter allowUserAgentAppInfo, default false). When enabled, the generated client exposes an optional AppInfo client option (Name, Version?, Comment?) on ClientOptions whose sanitized product token is appended to whatever User-Agent the SDK would otherwise send, per RFC 9110 §10.1.5. Independent of include-platform-headers. No IR change.

Gated / default-off (byte-identical)

Every appInfo artifact is emitted only when the flag is on:

  • the AppInfo record (new AppInfoGenerator, gated in SdkGeneratorCli),
  • the AppInfo? property on ClientOptions (+ wired into Clone() / copy-constructor),
  • the static AppendAppInfoToUserAgent helper (emitted only when the flag is on and a User-Agent is actually written),
  • the withAppInfo(...) wrapping of the User-Agent value.

Flag off ⇒ literal empty diff for every pre-existing fixture. The shared always-shipped core-utilities and BuildUserAgent() are never modified.

Where it's emitted / how it composes

The appendix wraps both User-Agent branches (gated):

  • the structured BuildUserAgent() value (include-platform-headers on), and
  • the non-structured buildUserAgentHeaderEntry value, which also covers the configured user-agent template value and the user-agent-name-from-package fallback.

AppInfo lives on ClientOptions, so it works in both unified-client-options and non-unified modes — the wrapping reads clientOptions.AppInfo, and clientOptions is already initialized (non-null) at the point the platform-headers dictionary is written. Runtime-version path is unaffected: BuildUserAgent() still resolves OS/arch/runtime at runtime; the appInfo token is appended around its result.

Before / after User-Agent (new fixture)

  • Before (include-platform-headers): { "User-Agent", BuildUserAgent() }
  • After (allow-user-agent-app-info): { "User-Agent", AppendAppInfoToUserAgent(BuildUserAgent(), clientOptions.AppInfo) }

Example rendered value (runtime-verified):
com.acme.sdk/1.4.0 (linux; x86_64) dotnet/8.0.4 partner-app/3.1.0 (+https://partner.example)

Sanitization (trim-before-encode; no whitespace-junk-token bug)

Name/Version/Comment are trimmed before the blank check and before encoding, so blank values are dropped rather than encoded into whitespace tokens. Then Name/Version are percent-encoded to RFC 7230 tchar (from UTF-8 bytes) and Comment has (, ), \ and control chars (incl. CR/LF, 0x7F) escaped. The helper uses only netstandard2.0/net462-safe APIs (StringBuilder + UTF-8 bytes, no regex), so no new package reference is required.

Sanitization cases covered (TS unit tests in buildAppInfoUserAgent.test.ts, plus the exact emitted helper compiled+run for runtime verification — all PASS):

  • null / empty / whitespace-only Name → User-Agent unchanged (incl. "\t\n ")
  • blank Version / blank Comment omitted; " app "/" 1.0 " trimmed to app/1.0
  • CRLF injection in name → ev%0D%0Ail; space in name → a%20b
  • paren injection in comment a) evil ((a%29 evil %28); backslash a\b(a%5Cb); CRLF in comment a\r\nb(a%0D%0Ab)

Empty-diff confirmation

Regenerated the imdb C# fixtures. Only tracked change among pre-existing fixtures was .fern/metadata.json churn (reverted). All other pre-existing fixtures are byte-identical; only the new imdb/allow-user-agent-app-info fixture is added.

TFMs compiled clean

dotnet build of the regenerated fixture succeeded for net462, net8.0, and netstandard2.0 (also net9.0) — 0 errors (the pre-existing CS1998 warning in RawClient.cs is unrelated). net462 compiled locally via the .NET 9 SDK's NuGet reference assemblies.

Changelog

generators/csharp/sdk/changes/unreleased/allow-user-agent-app-info.yml (type: feat). versions.yml untouched.

Checks

  • pnpm compile + pnpm seed:build clean.
  • @fern-api/fern-csharp-sdk unit tests: 86 passed (incl. 11 new).
  • npx biome check clean on all changed .ts. (Note: pnpm lint:eslint fails to even start locally due to a pre-existing ESLint 9 / eslintrc ajv toolchain error unrelated to this change.)

Generated with Claude Code


Open in Devin Review

Add a new SDK generator config `allow-user-agent-app-info` (kebab-case;
settings getter `allowUserAgentAppInfo`, default false). When enabled, the
generated client exposes an optional `AppInfo` client option (`Name`,
`Version?`, `Comment?`) on `ClientOptions` whose sanitized product token is
appended to whatever `User-Agent` the SDK would otherwise send, producing e.g.
`com.acme.sdk/1.4.0 (linux; x86_64) dotnet/8.0.4 partner-app/3.1.0 (+https://partner.example)`
per RFC 9110 §10.1.5.

The appendix is applied to both User-Agent branches, gated on the flag: the
structured `BuildUserAgent()` value (`include-platform-headers`) and the
non-structured `buildUserAgentHeaderEntry` value (which also covers the
configured `user-agent` template value and the `user-agent-name-from-package`
fallback). Caller-supplied values are trimmed before the blank check and before
encoding, so blank name/version/comment are dropped rather than encoded into
whitespace tokens; then name/version are percent-encoded to RFC 7230 `tchar`
and comment delimiters `(`, `)`, `\` and control chars (incl. CR/LF) are
escaped, so untrusted values cannot inject header content.

The `AppInfo` record, the `AppInfo` ClientOptions property, and the emitted
`AppendAppInfoToUserAgent` helper are only generated when the flag is on (and a
User-Agent is actually written), so default-off output is byte-identical and
the shared always-shipped core-utilities are never modified. Works in both
unified and non-unified client-options modes. Still overridable by an explicit
`User-Agent` header and suppressed by `omit-fern-headers`. No IR change.

The emitted helper uses only netstandard2.0/net462-safe APIs (StringBuilder +
UTF-8 bytes, no regex), so no new package reference is required; the new fixture
compiles clean for net462, net8.0, and netstandard2.0.

Co-Authored-By: Claude <noreply@anthropic.com>
@cadesark cadesark self-assigned this Jul 30, 2026

@nitpickybot nitpickybot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the changes — everything looks good. No issues found.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

View 1 additional finding in Devin Review.

Open in Devin Review

Comment on lines +123 to +129
`static void AppendPercentEncoded(${STRING_BUILDER} builder, char ch)`,
"{",
` foreach (var b in ${ENCODING}.UTF8.GetBytes(new[] { ch }))`,
" {",
" builder.Append('%').Append(b.ToString(\"X2\"));",
" }",
"}",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 App names containing emoji or other rare characters are garbled in the User-Agent header

Each character of the caller-supplied app name and version is converted to bytes one at a time (Encoding.UTF8.GetBytes(new[] { ch }) at generators/csharp/sdk/src/root-client/buildAppInfoUserAgent.ts:125) rather than in pairs, so any character represented by two code units is turned into replacement placeholders.
Impact: An app name or version containing emoji or other rare characters shows up as unreadable placeholder text in the User-Agent that servers and analytics see.

Per-char UTF-8 encoding destroys surrogate pairs

EncodeToken (generators/csharp/sdk/src/root-client/buildAppInfoUserAgent.ts:83-103) iterates foreach (var ch in value) and, for every character outside the RFC 7230 tchar set, calls AppendPercentEncoded(builder, ch). For a non-BMP scalar (e.g. U+1F680), value holds two UTF-16 surrogate code units; Encoding.UTF8.GetBytes on a single lone surrogate emits the replacement-character bytes EF BF BD, so the token becomes %EF%BF%BD%EF%BF%BD instead of the real UTF-8 bytes. EncodeComment (generators/csharp/sdk/src/root-client/buildAppInfoUserAgent.ts:107-122) also routes escaped chars through the same helper, though it only escapes ASCII delimiters/control chars there. A fix is to encode the whole string once (or detect char.IsHighSurrogate and encode the pair together) and percent-encode the resulting byte sequence for the characters that need encoding.

Prompt for agents
In generators/csharp/sdk/src/root-client/buildAppInfoUserAgent.ts, the emitted C# helper percent-encodes one UTF-16 char at a time via AppendPercentEncoded(builder, ch), which calls Encoding.UTF8.GetBytes(new[] { ch }). For characters outside the BMP the string contains a surrogate pair, and encoding each half separately yields the U+FFFD replacement bytes (%EF%BF%BD) twice, silently corrupting the app name/version in the User-Agent. Consider changing the emitted helper so that encoding operates on complete scalars: e.g. in EncodeToken/EncodeComment iterate by index, detect char.IsHighSurrogate(ch) with a following low surrogate and encode both chars together (Encoding.UTF8.GetBytes(value, i, 2) or new[] { high, low }), or pre-encode the whole string to UTF-8 bytes and decide per byte whether it needs percent-encoding. Keep to netstandard2.0/net462-safe APIs (no regex, no Rune) and update the unit tests in generators/csharp/sdk/src/root-client/__test__/buildAppInfoUserAgent.test.ts accordingly.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@github-actions

Copy link
Copy Markdown
Contributor

SDK Generation Benchmark Results

Comparing PR branch against median of 5 nightly run(s) on main (latest: 2026-07-30T05:00:19Z).

Full benchmark table (click to expand)
Generator Spec main (generator) main (E2E) PR (generator) Delta
csharp-sdk square 77s (n=5) N/A 70s -7s (-9.1%)

main (generator): generator-only time via --skip-scripts (includes Docker image build, container startup, IR parsing, and code generation — this is the same Docker-based flow customers use via fern generate). main (E2E): full customer-observable time including build/test scripts (nightly baseline, informational). Delta is computed against generator-only baseline.
⚠️ = generation exited with a non-zero exit code (timing may not reflect a successful run).
Baseline from nightly runs on main (latest: 2026-07-30T05:00:19Z). Trigger benchmark-baseline to refresh.
Last updated: 2026-07-30 16:36 UTC

cadesark added a commit that referenced this pull request Jul 30, 2026
…tformHeaders

Adds a new imdb fixture variant `include-platform-headers-app-info` that
sets both `includePlatformHeaders: true` and `allowUserAgentAppInfo: true`,
proving the appInfo User-Agent appender composes with the structured
platform User-Agent branch (getPlatformUserAgent), not just the default
`{package}/{version}` literal. Mirrors the Java (#17301) and C# (#17303)
combined fixtures.

Co-Authored-By: Claude <noreply@anthropic.com>
cadesark added a commit that referenced this pull request Jul 30, 2026
…atformHeaders

Adds a new `include-platform-headers-app-info` variant of the `examples`
fixture that sets both `includePlatformHeaders: true` and
`allowUserAgentAppInfo: true`. This proves the appInfo User-Agent token
composes with the structured/platform User-Agent branch
(`RawClient.user_agent(...)`) via `append_app_info(...)`, mirroring the
Java (#17301) and C# (#17303) fixtures.

Co-Authored-By: Claude <noreply@anthropic.com>
@cadesark
cadesark merged commit 0da7273 into main Aug 3, 2026
78 checks passed
@cadesark
cadesark deleted the cade/csharp-user-agent-app-info branch August 3, 2026 16:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants