fix(anthropic): wire buildGuardedModelFetch into the API-key createClient branch#98017
Conversation
…ient branch This brings the API-key Anthropic SDK client to guarded-fetch parity with sibling providers. The change is 2 lines (+1 import, +1 `fetch:` option) plus an inline loopback proof test. This is one of five split PRs (Cloudflare / Copilot / Foundry / OAuth / API-key) that supersede openclaw#97868. Each PR is one constructor's blast radius — separate from the others. Land any combination without affecting the rest.
|
Codex review: needs maintainer review before merge. Reviewed June 30, 2026, 12:11 AM ET / 04:11 UTC. Summary PR surface: Source +2, Tests +226. Total +228 across 3 files. Reproducibility: yes. source-reproducible. Current main reaches the Anthropic API-key SDK constructor without a custom fetch option, and the pinned SDK falls back to global fetch when that option is absent. Review metrics: 1 noteworthy metric.
Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Rank-up moves:
Risk before merge
Maintainer options:
Next step before merge
Security Review detailsBest possible solution: Land the narrow API-key guarded-fetch wiring after maintainers accept the intentional fail-closed transport policy for this provider path. Do we have a high-confidence way to reproduce the issue? Yes, source-reproducible. Current main reaches the Anthropic API-key SDK constructor without a custom fetch option, and the pinned SDK falls back to global fetch when that option is absent. Is this the best way to solve the issue? Yes, subject to maintainer compatibility acceptance. Constructor-level AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against 85ee71223f0d. Label changesLabel justifications:
Evidence reviewedPR surface: Source +2, Tests +226. Total +228 across 3 files. View PR surface stats
What I checked:
Likely related people:
What the crustacean ranks mean
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics. How this review workflow works
|
|
Closing this PR as superseded by #99059 ("extract reusable AI runtime package"), which moved The new branch-isolated PR is on its way. |
What Problem This Solves
The Anthropic provider (
anthropic.ts) creates fiveAnthropicSDK clients — one for API-key, one for GitHub Copilot, one for Microsoft Foundry bearer auth, one for OAuth, and one for the API-key fallback. None of them passes a customfetchoption. Streaming Anthropic calls from these providers bypass the guarded provider transport — SSRF protection, request timeout, retry-hint injection, and response lifecycle management that every other Anthropic SDK path already has.This injects
fetch: buildGuardedModelFetch(model)into the API-key SDK constructor specifically, bringing that branch to guarded-fetch parity with:anthropic-transport-stream.ts:788(managed Anthropic transport)openai-responses.ts(fix(openai-responses): bound SSE response reads via buildGuardedModelFetch #97848)openai-completions.ts(fix(openai-completions): bound SSE response reads via buildGuardedModelFetch #97228)azure-openai-responses.tsThis is one of five split PRs that supersede #97868. Each PR is one constructor's blast radius — separate from the others. Maintainers can land any combination independently.
Why This Change Was Made
buildGuardedModelFetchis the canonical fetch wrapper applied to every Anthropic SDK constructor in the codebase. The Anthropic SDK acceptsfetch?: Fetchper its constructor options. The API-key branch was missing this wrapper.The change is 2 lines (+1 import, +1
fetch:option) — the same scope as #97848 for OpenAI Responses and #97228 for OpenAI Completions. This PR also adds an inlinehttp.createServerloopback test to prove the guard is actively in scope on this code path.Evidence
Layer 1: Mock-based constructor wiring
anthropic.test.tsalready mocks the Anthropic SDK to capture constructor config. The existing API-key test was extended to assert the newfetch:option is in scope:it("keeps API-key upstream provider auth on the Anthropic API key", async () => { ... const config = anthropicMockState.configs[0] as { apiKey?: string | null; authToken?: string | null; defaultHeaders?: Record<string, string | null>; + fetch?: unknown; }; expect(config.apiKey).toBe("sk-ant-provider"); expect(config.authToken).toBeNull(); expect(config.defaultHeaders?.["x-api-key"]).toBeUndefined(); expect(config.defaultHeaders?.["cf-aig-authorization"]).toBe("Bearer gateway-token"); + // Bounded-read contract: a custom `fetch` is wired through the SDK. + expect(typeof config.fetch).toBe("function"); });Negative control: removing
fetch: buildGuardedModelFetch(model)makestypeof config.fetch === "function"fail.Layer 2: SSRF-block guard-specific proof
anthropic-apikey.fetch-proof.test.tsprovesbuildGuardedModelFetchis actively blocking requests rather than just being present in constructor options:globalThis.fetchas a call counter — does NOT mock theanthropic-ai/sdkbaseUrl: "http://169.254.169.254/v1"— a cloud metadata IP that the SSRF guard blocksstreamAnthropicthrough the real SDK +buildGuardedModelFetchglobalThis.fetchis ever reachedexpect(globalFetchCalled).toBe(0)— guard-specific behaviorWhy this is needed: The raw
@anthropic-ai/sdkusesglobalThis.fetchby default (when no customfetchoption is supplied). A test that only asserts the SDK reachesglobalThis.fetchwould pass even withoutbuildGuardedModelFetch. The SSRF-block assertion closes that gap.Test results (real terminal output)
All tests pass consistently.
Layer 3: Real wire loopback proof (vitest inline test)
anthropic-apikey.fetch-proof.test.tsadds a seconddescribe(...)blockAnthropic API-key guard real wire loopback proof (http.createServer)that spins up a realnode:httpserver on127.0.0.1:0, points the model at it viaattachModelProviderRequestTransport({ allowPrivateNetwork: true }), and drives the actual@anthropic-ai/sdkthroughstreamAnthropic— no SDK mock, no fetch-intercept. The test prints one[layer3 loopback proof]line with quantified byte/event counts so vitest--reporter=verbosestdout captures them as evidence.Run from worktree branch
fix/anthropic-cloudflare-fetch-wiring:Quantified read of the loopback proof:
server_hitshttp.createServerlistener actually received the SDK's POSTrequest_url/v1/messages@anthropic-ai/sdkappended/v1/messagesto baseUrl (correct shape)request_bytestext_bytesstreamAnthropicfromcontent_block_deltaeventsstop_reasonstopallowPrivateNetwork: trueLayer 3 reads:
server_hits=1confirms the localhttp.createServerlistener actually received the SDK's POST. This is real wire traffic, not aglobalThis.fetchspy.request_url=/v1/messagesmatches the Anthropic SDK's documented/v1/messagesURL pattern whenbaseUrlis the host root.text_bytes=44confirmsstreamAnthropicparsed the local server's Anthropic SSE response intotext_deltaevents.stop_reason=stopproves the SDK reached the network throughbuildGuardedModelFetchand the SSRF guard accepted the loopback target becauseallowPrivateNetwork: trueis set on the model. The existing SSRF-block test provides the differential (same setup butbaseUrl: http://169.254.169.254/v1→globalFetchCalled === 0).Real behavior proof
createClientinsrc/llm/providers/anthropic.ts(API-key branch) now passesfetch: buildGuardedModelFetch(model)tonew Anthropic({...}), so Anthropic calls routed through the API-key inherit OpenClaw guarded-fetch policy (SSRF block, request timeout, retry-hint injection, response lifecycle management, content-type normalization).fix/anthropic-cloudflare-fetch-wiring(commitb0039d6405).node scripts/run-vitest.mjs src/llm/providers/anthropic-apikey.fetch-proof.test.ts --run --reporter=verboseanthropic.test.tsasserts the constructor wiring (existing tests preserved, +5 lines).anthropic-apikey.fetch-proof.test.tsSSRF-block test stubsglobalThis.fetch, configuresbaseUrl: http://169.254.169.254/v1, assertsglobalFetchCalled === 0(block before globalThis.fetch is reached).anthropic-apikey.fetch-proof.test.tsloopbackdescribeblock spins uphttp.createServeron127.0.0.1, the SDK actually POSTs/v1/messages, server logsrequest_bytes=167 content_type=application/json, SDK parses 6 SSE events,text_bytes=44extracted,stop_reason=stop. The[layer3 loopback proof]line is emitted by the test'sconsole.log.anthropic-apikey.fetch-proof.test.tspass; 36/36 existing tests inanthropic.test.tsstill pass. Layer 1+2+3 together prove: (a)buildGuardedModelFetchis wired into the API-key constructor, (b) the guard actively intercepts blocked targets beforeglobalThis.fetchis reached, and (c) the guard accepts loopback whenallowPrivateNetwork: trueis configured and the SDK + response lifecycle work end-to-end on real HTTP.provider-transport-fetch.test.ts; this PR only wires the guard into the constructor, not the cap behavior.Diff scope
Security & Privacy
fetchoption is standard Anthropic SDK API surface; no monkey-patching.Compatibility
What was not tested
provider-transport-fetch.test.ts; this PR only proves the guard is wired into the API-key constructor.Related
buildGuardedModelFetchinsrc/agents/provider-transport-fetch.ts:776