Nucleic: Backend Claude OAuth Token Refresh

This commit is contained in:
2026-08-04 00:36:35 -07:00
parent c0f106e68a
commit 0a00f22f00
2 changed files with 24 additions and 6 deletions
@@ -154,14 +154,16 @@ public actor ClaudeTokenProxy {
}
}
/// Serialize an upstream response back to the client: status line, headers (hop-by-hop stripped,
/// `Connection: close` added, `Content-Length`/`Transfer-Encoding` dropped since we stream and
/// close), then the body chunks as they arrive.
/// Serialize an upstream response back to the client: status line, headers (hop-by-hop and stale
/// representation framing stripped), `Connection: close`, then the body chunks as they arrive.
/// URLSession transparently decodes compressed upstream bodies, so forwarding the original
/// `Content-Encoding` would make Claude's fetch decode those bytes a second time (`ZlibError`).
static func writeResponse(_ response: UpstreamResponse, to writer: ByteWriter) async {
var head = "HTTP/1.1 \(response.status) \(reasonPhrase(response.status))\r\n"
for (name, value) in response.headers {
let lower = name.lowercased()
if lower == "content-length" || lower == "transfer-encoding" || lower == "connection" { continue }
if lower == "content-length" || lower == "transfer-encoding"
|| lower == "content-encoding" || lower == "connection" { continue }
head += "\(name): \(value)\r\n"
}
head += "Connection: close\r\n\r\n"
@@ -238,6 +240,11 @@ public actor ClaudeTokenProxy {
for (name, value) in request.headers {
urlRequest.setValue(value, forHTTPHeaderField: name)
}
// Claude's fetch advertises gzip/br. URLSession decodes compressed responses before exposing
// their bytes but can retain the upstream Content-Encoding header, which would make the
// downstream fetch decompress the decoded stream again. Request identity as the primary
// defense; writeResponse also drops stale encoding metadata defensively.
urlRequest.setValue("identity", forHTTPHeaderField: "Accept-Encoding")
if !request.body.isEmpty { urlRequest.httpBody = request.body }
#if canImport(FoundationNetworking)
@@ -152,7 +152,11 @@ struct ClaudeTokenProxyTests {
let stub = UpstreamStub { _ in
Self.response(
status: 200,
headers: [("content-type", "text/event-stream"), ("content-length", "999")],
headers: [
("content-type", "text/event-stream"),
("content-encoding", "gzip"),
("content-length", "999"),
],
chunks: ["event: a\ndata: 1\n\n", "event: b\ndata: 2\n\n"])
}
let proxy = ClaudeTokenProxy(tokenProvider: { "t" }, upstream: stub.upstream())
@@ -167,6 +171,8 @@ struct ClaudeTokenProxyTests {
#expect(out.contains("Connection: close"))
// We stream + close, so the upstream framing headers are dropped (they'd mislead the client).
#expect(!out.lowercased().contains("content-length: 999"))
// URLSession has already decoded the body; advertising gzip makes fetch double-decode it.
#expect(!out.lowercased().contains("content-encoding"))
#expect(out.contains("event: a\ndata: 1\n\n"))
#expect(out.contains("event: b\ndata: 2\n\n"))
}
@@ -208,7 +214,12 @@ struct ClaudeTokenProxyTests {
#if canImport(Network) || os(Linux)
@Test func loopbackListenerInjectsTokenEndToEnd() async throws {
let stub = UpstreamStub { _ in
Self.response(status: 200, headers: [("content-type", "text/plain")], chunks: ["ok"])
// Model URLSession's decoded bytes plus the stale upstream gzip header that caused
// Claude/undici to throw ZlibError before the proxy normalized response metadata.
Self.response(
status: 200,
headers: [("content-type", "text/plain"), ("content-encoding", "gzip")],
chunks: ["ok"])
}
let proxy = ClaudeTokenProxy(tokenProvider: { "fresh" }, upstream: stub.upstream())
let port = try await proxy.start()