Skip to content

Navigation Menu

Sign in
Sign up

fix(api,web): repair gaia api chat completions, HTTPS SNI, and download overwrite - #3364

Open
kovtcharov wants to merge 4 commits into
amd:main from
kovtcharov:fix/api-kwarg-and-web-fetch
Open

fix(api,web): repair gaia api chat completions, HTTPS SNI, and download overwrite #3364
kovtcharov wants to merge 4 commits into
amd:main from
kovtcharov:fix/api-kwarg-and-web-fetch

Conversation

@kovtcharov

@kovtcharov kovtcharov commented Sep 4, 2026
edited
Loading

Copy link
×ばつ slower, which accounts for the gap on the token-heaviest scenario (8335→2083 tokens). The other three scenarios are *faster* in wall-clock despite that. The docstring addition is ~25 tokens on one tool description, 0.3% of that prompt. <details> <summary>🔍 Two pre-existing eval-harness defects found while getting this to run</summary> Both are untouched by this PR and are filed separately as #3367 and #3368: - **`runner.py:949` adds `--bare` whenever `ANTHROPIC_API_KEY` is non-empty**, and `--bare` restricts auth to that key — OAuth and keychain are never consulted. Because `load_dotenv()` walks up parent directories, a `.env` anywhere above the checkout injects a key into the process even when the shell has none, silently disabling working subscription auth. A stale key then surfaces as HTTP 400 `"Credit balance is too low"`, which points at billing rather than at the override. This is the "no silent fallbacks" rule in CLAUDE.md: it should fail loudly on an auth error, or prefer OAuth when the key is rejected. - **The runner discards the judge subprocess body on failure**, recording `"error": ""`. The real cause above was invisible until the call was instrumented by hand. </details> ## Evidence **HTTP API / REST** — real requests against `gaia api start --port 8199` with the flagship agent installed and Lemonade serving `Gemma-4-E4B-it-GGUF`. Before (this commit's parent): ``` $ curl -sS -X POST http://127.0.0.1:8199/v1/chat/completions -H 'Content-Type: application/json' \ -d '{"model":"gaia","messages":[{"role":"user","content":"What is 17 * 23? Answer with just the number."}],"stream":false}' -w '\nHTTP_STATUS=%{http_code}\n' Internal Server Error HTTP_STATUS=500 # server log: result = agent.process_query(user_message, workspace_root=workspace_root) TypeError: Agent.process_query() got an unexpected keyword argument 'workspace_root' ``` After — same request, same server: ``` {"id":"chatcmpl-675e7b945f51450595107565","object":"chat.completion","created":1788546133,"model":"gaia", "choices":[{"index":0,"message":{"role":"assistant","content":"391","tool_calls":null},"finish_reason":"stop"}], "usage":{"prompt_tokens":11,"completion_tokens":0,"total_tokens":11}} HTTP_STATUS=200 ``` Streaming path — 7 chunks, terminates with `data: [DONE]`, no `"error"` in the body. A Copilot `<workspace_info>` payload also returns 200 (`5 plus 6` → `11`). <details> <summary>🔍 Web fetch — before/after probe across 11 hosts</summary> Same script, same machine, only `client.py` differing. `www.amd.com` times out identically both ways and through plain `requests`, so it is not an SNI failure. | Host | `WebClient` before | `WebClient` after | plain `requests` | |---|---|---|---| | example.com | FAIL SSLError | **OK 200** | OK 200 | | github.com/amd/gaia | FAIL SSLError | **OK 200** | OK 200 | | pypi.org | FAIL SSLError (cert `*.python.org`) | **OK 200** | OK 200 | | huggingface.co | FAIL SSLError | **OK 200** | OK 200 | | amd-gaia.ai | FAIL SSLError | **OK 200** | OK 200 | | lemonade-server.ai | FAIL SSLError | **OK 200** | OK 200 | | arxiv.org | FAIL SSLError (cert `s.sni-810-default.ssl.fastly.net`) | **OK 200** | OK 200 | | stackoverflow.com | FAIL SSLError | **OK 403** | OK 403 | | www.amd.com | FAIL ReadTimeout | FAIL ReadTimeout | FAIL ReadTimeout | | en.wikipedia.org | OK 200 | OK 200 | OK 200 | | duckduckgo.com | OK 200 | OK 200 | OK 200 | **9 of 11 failed → 1 of 11 fails**, and the one remaining failure is identical under plain `requests`. Security posture re-verified after the change: ``` PASS validate_url still checks EVERY answer (127.0.0.1 in the answer set -> blocked) PASS adapter still re-validates the exact dialed IP (169.254.169.254 -> blocked) PASS scheme guard rejects file:// and ftp://; port guard rejects :22 PASS _request and download both re-validate each redirect hop PASS host_params={'scheme':'https','host':'104.20.23.154'}, server_hostname='example.com' PASS wrong.host.badssl.com / expired.badssl.com / self-signed.badssl.com all rejected (SSLError) ``` </details> <details> <summary>🔍 Download — live run of the download_file tool</summary> Live run of the **tool** (not `WebClient` directly), into a directory that already holds a `README.md`: ``` === BEFORE (upstream/main) === $ cat README.md MY OWN IMPORTANT NOTES >>> download_file("https://raw.githubusercontent.com/amd/gaia/main/README.md", save_to=...) Downloaded: README.md Size: 7.3 KB Type: text/plain; charset=utf-8 $ cat README.md # <img src="https://raw.githubusercontent.com/amd/gaia/main/ <-- user's notes destroyed, tool reported success === AFTER (this branch) === $ cat README.md MY OWN IMPORTANT NOTES >>> download_file("https://raw.githubusercontent.com/amd/gaia/main/README.md", save_to=...) Error: Refusing to overwrite existing file: ...\README.md. Pass an explicit filename= to download under a different name. $ cat README.md MY OWN IMPORTANT NOTES <-- intact $ ls ['README.md'] >>> same tool, a free filename Downloaded: gaia-readme.md Size: 7.3 KB $ ls ['README.md', 'gaia-readme.md'] ``` </details> <details> <summary>🔍 Runtime guard against an older requests</summary> `build_connection_pool_key_attributes` only exists in requests >= 2.32.3, so on anything older the SNI override silently never runs. `PinnedIPAdapter` now refuses to construct. Verified against real wheels: | requests | result | |---|---| | 2.31.0 | refused | | 2.32.2 | refused (routes through `get_connection_with_tls_context` but ships no hook) | | 2.32.3 | constructs | | 2.34.2 | constructs | ``` RuntimeError: PinnedIPAdapter needs requests>=2.32.3 to send the correct TLS SNI while pinning the IP, but requests 2.31.0 is installed. Without it every HTTPS fetch names the pinned IP in the handshake and CDN-fronted hosts reject the connection. Upgrade with: pip install --upgrade 'requests>=2.32.3' ``` </details> <details> <summary>🔍 Download — WebClient-level before/after</summary> A directory containing the user's own `README.md`, then downloading a URL whose name resolves to `README.md`: ``` === BEFORE === before: README.md = b'MY OWN IMPORTANT NOTES' download returned: 7463 bytes after: README.md = b'# <img src="https://raw.githubuserconten' <-- user's file destroyed === AFTER === before: README.md = b'MY OWN IMPORTANT NOTES' download refused: ValueError: Refusing to overwrite existing file: ...\README.md. Pass an explicit filename= to download under a different name. after: README.md = b'MY OWN IMPORTANT NOTES' <-- intact dir now: ['README.md'] fresh download OK: 7463 bytes, dir now: ['README.md', 'fresh.md'] <-- a free name still works ``` </details> - [x] **HTTP API / REST** — real request and response above. - [x] **CLI** — `gaia api start` used to produce the evidence above. - [ ] **Agent exposed in the Agent UI** — N/A, no Agent UI surface changed. - [ ] **MCP tools / servers** — N/A, no MCP surface changed. ## Checklist - [x] I have linked a GitHub issue above (`Closes #3360`). - [x] I have described **why** this change is being made, not just what changed. - [x] I have run linting and tests locally (`python util/lint.py --all`, `pytest tests/unit/`). - [x] I have attached **real-world evidence matched to the surface I changed**, or marked each surface N/A. - [x] I have updated documentation if user-visible behavior changed — `docs/spec/api-server.mdx` (removed the workspace-root section and renumbered the flow) and `docs/spec/browser-tools.mdx` + the `@tool` docstring (the new overwrite refusal, so the model learns the rule instead of discovering it from an error). " data-view-component="true"> Copy Markdown
Contributor

Summary

Fixes three shipped features that are broken in ordinary use: gaia api returned HTTP 500 on every chat completion, web fetch failed on most HTTPS sites, and a download could silently replace a file the user already had.

Why

POST /v1/chat/completions failed on every request, streaming or not — the server passed a workspace_root= keyword that no agent accepts, so the call raised before reaching the model. The whole endpoint was dead for anyone running the flagship agent.

Web fetch was failing on most of the web. The IP-pinning adapter closed a DNS-rebinding window by rewriting the request URL's host to the resolved IP, which meant every HTTPS request went out with no SNI — so any host that picks its certificate by SNI returned the wrong one or refused the handshake. Measured live, 9 of 11 hosts failed through WebClient while succeeding through plain requests, including GitHub, PyPI, Hugging Face and GAIA's own amd-gaia.ai. "Summarise this GitHub URL" returned an SSL error.

The third is the reason the first two ship together. download_file took its filename from the remote Content-Disposition header and opened the destination with no existence check, so a page serving filename=report.pdf replaced the user's own ~/Downloads/report.pdf. Today the handshake usually fails first, so the write path is rarely reached — fixing SNI un-gates it. It is fixed in the earlier commit on purpose.

Linked issue

Closes #3360

Changes

  • API — dropped the workspace_root kwarg and the extract_workspace_root helper that fed it. Nothing consumed it: its only consumer was a deleted agent, and no agent implements the set_workspace_root hook the spec described. A Copilot <workspace_info> block is now ordinary message text.
  • Web fetch — SNI is set via build_connection_pool_key_attributes, the extension point requests documents for this, so the socket still dials the validated pinned IP while TLS names the real host. assert_hostname is deliberately not set: it moves name checking out of the handshake and flips check_hostname = False on an SSL context requests shares process-wide on some versions. requests is pinned >=2.32.32.32.2 is specifically unusable, it routes through get_connection_with_tls_context but has no such hook, so SNI would silently revert to the IP.
  • Download — the destination is checked, and handed to the caller's policy screen, before anything is created; the body streams to a .part file renamed into place only on success. The sensitive-filename blocklist moved from after the write (where it reacted by deleting the file, and only when a _path_validator was attached) to before it.
  • The live TLS test is wired into the unit-tests workflow — an adapter like this can pass every offline test and still fail every real handshake.

The rebinding defence is unchanged and re-verified: validate_url still rejects a hostname if any answer is private, the adapter still re-validates the exact IP it dials, and each redirect hop is still re-validated.

Test plan

  • python util/lint.py --all — Black / isort / Flake8 / Bandit pass. Pylint reports 9 pre-existing errors, all os.killpg/geteuid Windows false positives in daemon/sidecars/* and installer/lemonade_installer.py; none in a file this PR touches.
  • pytest tests/unit/test_browser_tools.py — 73 passed
  • pytest tests/unit/test_web_client_ip_pinning.py tests/integration/test_web_client_live_sni.py — 24 passed
  • pytest tests/test_api.py — 57 passed, 22 skipped (skips are gaia_agent_email-gated)
  • Every unit/integration test file that imports the three changed modules — 462 passed, 0 failed.
  • Pre-existing baseline, so a reviewer is not surprised: on Windows the full pytest tests/unit/ has ~1000 failures/errors from the known socket.socketpair() / _block_network guard, and tests/test_sdk.py fails 17 tests. Both reproduce identically with this branch's source files reverted to upstream/main. None are in a file this PR touches. CI runs these on ubuntu, where the socket guard does not fire.
  • New tests fail against the previous code, which is the point: 3/3 API tests fail with TypeError: Agent.process_query() got an unexpected keyword argument 'workspace_root'; 5/7 live TLS tests fail with SSLError; 6/8 download tests fail.
  • CI lanes confirmed to actually run them, not skip them. tests/unit/**test_unit.yml (pytest tests/unit/, installs .[api] + pytest-mock). tests/test_api.pytest_api.yml (triggers on src/gaia/api/** and tests/test_api.py, installs .[dev,api], so fastapi is present and the new class runs rather than skipping). tests/integration/test_web_client_live_sni.py had no lane — added as a step to test_unit.yml, alongside the existing live-upstream Lemonade asset test. Verified with -v that every new test reports PASSED, none SKIPPED.

Agent eval — tool_selection, no regressions

This touches an LLM-affecting surface (two lines added to download_file's @tool docstring), so per CLAUDE.md an eval is required. It ran against this branch at 87b9479c — the Agent UI backend on :4200 was started from this worktree's own venv, so the numbers describe this PR's code, not an installed release. Lemonade served Gemma-4-E4B-it-GGUF on GPU at ctx_size=32768. tool_selection is the category the changed tool docstring feeds.

gaia eval agent --category tool_selection
→ Results: 4/5 passed (80% all, 80% judged), Avg score: 8.7/10
 Output: eval/results/eval-20260904-210550/
gaia eval agent --compare \
 tests/fixtures/eval_baselines/gemma-4-e4b-d71cd914/scorecard_tool_selection.json \
 eval/results/eval-20260904-210550/scorecard.json
🔍 `gaia eval agent --compare` — the tool's own output
SCORECARD COMPARISON
 Baseline : tests\fixtures\eval_baselines\gemma-4-e4b-d71cd914\scorecard_tool_selection.json
 Current : eval\results\eval-20260904-210550\scorecard.json
======================================================================
METRIC BASELINE CURRENT DELTA
--------------------------------------------------------------
Pass rate (all) 75% 80% +5%
Pass rate (judged) 75% 80% +5%
Avg score 8.4 8.7 +0.3
Scenarios 4 5
[+] IMPROVED (1 scenario(s)) — FAIL → PASS:
 known_path_read 6.7 → 9.6 (+2.9)
[~] TIME REGRESSION (1 scenario(s)) — elapsed > 2x baseline:
 smart_discovery 179.1s → 417.2s
[~] SCORE CHANGED, STATUS SAME (1 scenario(s)):
 multi_step_plan PASS 7.3 → 9.8 (+2.5)
[=] UNCHANGED (1 scenario(s)):
 no_tools_needed PASS 10.0
[+] ONLY IN CURRENT (1 scenario(s)) — new scenarios:
 data_vs_recall_disambiguation
======================================================================
[WARN] 1 time regression(s) detected (elapsed time > 2x baseline)!
[OK] Net improvement: 1 scenario(s) fixed, 0 regressions.
======================================================================
[ERROR] Detected 1 issue(s) (status regressions, score regressions, or time regressions); failing.
Metric Baseline Current Δ
Pass rate (all) 75% 80% +5%
Pass rate (judged) 75% 80% +5%
Avg score 8.4 8.7 +0.3
Scenarios 4 5 +1

Per scenario:

Scenario Baseline Current
known_path_read FAIL 6.7 PASS 9.6
multi_step_plan PASS 7.3 PASS 9.8
no_tools_needed PASS 10.0 PASS 10.0
smart_discovery PASS 9.5 PASS 8.1
data_vs_recall_disambiguation not in baseline FAIL 6.4

No status or score regressions. The baseline's one failing scenario now passes.

Three caveats, so the numbers are not read for more than they are worth:

  • The score deltas are not purely behavioural. The baseline was judged by claude-sonnet-4-6, this run by claude-opus-5; the compare tool warns about exactly this. Treat "no regression" as the signal, not the +2.5 on multi_step_plan.
  • data_vs_recall_disambiguation has no baseline entry because it did not exist yet: it was added 2026年06月25日 in test(tool-loader): pin #800 doc-profile data-vs-recall disambiguation #1844 , two months after the baseline was captured (2026年04月24日). The extra scenario is not a discrepancy. On the 4 overlapping scenarios the result is 4/4 pass vs the baseline's 3/4.
  • Zero scenarios errored. The same command errored all 5 at 0 turns before the auth cause below was found (run eval-20260904-210157), so the clean run is itself the confirmation that the diagnosis was right.
  • The one flagged "time regression" is hardware, not this change. smart_discovery went 179s → 417s, but the baseline machine sustained 109–115 tok/s where this one runs at 38–45 tok/s — ×ばつ slower, which accounts for the gap on the token-heaviest scenario (8335→2083 tokens). The other three scenarios are faster in wall-clock despite that. The docstring addition is ~25 tokens on one tool description, 0.3% of that prompt.
🔍 Two pre-existing eval-harness defects found while getting this to run

Both are untouched by this PR and are filed separately as #3367 and #3368:

  • runner.py:949 adds --bare whenever ANTHROPIC_API_KEY is non-empty, and --bare restricts auth to that key — OAuth and keychain are never consulted. Because load_dotenv() walks up parent directories, a .env anywhere above the checkout injects a key into the process even when the shell has none, silently disabling working subscription auth. A stale key then surfaces as HTTP 400 "Credit balance is too low", which points at billing rather than at the override. This is the "no silent fallbacks" rule in CLAUDE.md: it should fail loudly on an auth error, or prefer OAuth when the key is rejected.
  • The runner discards the judge subprocess body on failure, recording "error": "". The real cause above was invisible until the call was instrumented by hand.

Evidence

HTTP API / REST — real requests against gaia api start --port 8199 with the flagship agent installed and Lemonade serving Gemma-4-E4B-it-GGUF.

Before (this commit's parent):

$ curl -sS -X POST http://127.0.0.1:8199/v1/chat/completions -H 'Content-Type: application/json' \
 -d '{"model":"gaia","messages":[{"role":"user","content":"What is 17 * 23? Answer with just the number."}],"stream":false}' -w '\nHTTP_STATUS=%{http_code}\n'
Internal Server Error
HTTP_STATUS=500
# server log:
result = agent.process_query(user_message, workspace_root=workspace_root)
TypeError: Agent.process_query() got an unexpected keyword argument 'workspace_root'

After — same request, same server:

{"id":"chatcmpl-675e7b945f51450595107565","object":"chat.completion","created":1788546133,"model":"gaia",
 "choices":[{"index":0,"message":{"role":"assistant","content":"391","tool_calls":null},"finish_reason":"stop"}],
 "usage":{"prompt_tokens":11,"completion_tokens":0,"total_tokens":11}}
HTTP_STATUS=200

Streaming path — 7 chunks, terminates with data: [DONE], no "error" in the body. A Copilot <workspace_info> payload also returns 200 (5 plus 611).

🔍 Web fetch — before/after probe across 11 hosts

Same script, same machine, only client.py differing. www.amd.com times out identically both ways and through plain requests, so it is not an SNI failure.

Host WebClient before WebClient after plain requests
example.com FAIL SSLError OK 200 OK 200
github.com/amd/gaia FAIL SSLError OK 200 OK 200
pypi.org FAIL SSLError (cert *.python.org) OK 200 OK 200
huggingface.co FAIL SSLError OK 200 OK 200
amd-gaia.ai FAIL SSLError OK 200 OK 200
lemonade-server.ai FAIL SSLError OK 200 OK 200
arxiv.org FAIL SSLError (cert s.sni-810-default.ssl.fastly.net) OK 200 OK 200
stackoverflow.com FAIL SSLError OK 403 OK 403
www.amd.com FAIL ReadTimeout FAIL ReadTimeout FAIL ReadTimeout
en.wikipedia.org OK 200 OK 200 OK 200
duckduckgo.com OK 200 OK 200 OK 200

9 of 11 failed → 1 of 11 fails, and the one remaining failure is identical under plain requests.

Security posture re-verified after the change:

PASS validate_url still checks EVERY answer (127.0.0.1 in the answer set -> blocked)
PASS adapter still re-validates the exact dialed IP (169.254.169.254 -> blocked)
PASS scheme guard rejects file:// and ftp://; port guard rejects :22
PASS _request and download both re-validate each redirect hop
PASS host_params={'scheme':'https','host':'104.20.23.154'}, server_hostname='example.com'
PASS wrong.host.badssl.com / expired.badssl.com / self-signed.badssl.com all rejected (SSLError)
🔍 Download — live run of the download_file tool

Live run of the tool (not WebClient directly), into a directory that already holds a README.md:

=== BEFORE (upstream/main) ===
$ cat README.md
MY OWN IMPORTANT NOTES
>>> download_file("https://raw.githubusercontent.com/amd/gaia/main/README.md", save_to=...)
Downloaded: README.md
 Size: 7.3 KB
 Type: text/plain; charset=utf-8
$ cat README.md
# <img src="https://raw.githubusercontent.com/amd/gaia/main/ <-- user's notes destroyed,
 tool reported success
=== AFTER (this branch) ===
$ cat README.md
MY OWN IMPORTANT NOTES
>>> download_file("https://raw.githubusercontent.com/amd/gaia/main/README.md", save_to=...)
Error: Refusing to overwrite existing file: ...\README.md.
 Pass an explicit filename= to download under a different name.
$ cat README.md
MY OWN IMPORTANT NOTES <-- intact
$ ls
['README.md']
>>> same tool, a free filename
Downloaded: gaia-readme.md
 Size: 7.3 KB
$ ls
['README.md', 'gaia-readme.md']
🔍 Runtime guard against an older requests

build_connection_pool_key_attributes only exists in requests >= 2.32.3, so on anything older the SNI override silently never runs. PinnedIPAdapter now refuses to construct. Verified against real wheels:

requests result
2.31.0 refused
2.32.2 refused (routes through get_connection_with_tls_context but ships no hook)
2.32.3 constructs
2.34.2 constructs
RuntimeError: PinnedIPAdapter needs requests>=2.32.3 to send the correct TLS SNI while
pinning the IP, but requests 2.31.0 is installed. Without it every HTTPS fetch names the
pinned IP in the handshake and CDN-fronted hosts reject the connection.
Upgrade with: pip install --upgrade 'requests>=2.32.3'
🔍 Download — WebClient-level before/after

A directory containing the user's own README.md, then downloading a URL whose name resolves to README.md:

=== BEFORE ===
before: README.md = b'MY OWN IMPORTANT NOTES'
download returned: 7463 bytes
after: README.md = b'# <img src="https://raw.githubuserconten' <-- user's file destroyed
=== AFTER ===
before: README.md = b'MY OWN IMPORTANT NOTES'
download refused: ValueError: Refusing to overwrite existing file: ...\README.md.
 Pass an explicit filename= to download under a different name.
after: README.md = b'MY OWN IMPORTANT NOTES' <-- intact
dir now: ['README.md']
fresh download OK: 7463 bytes, dir now: ['README.md', 'fresh.md'] <-- a free name still works
  • HTTP API / REST — real request and response above.
  • CLIgaia api start used to produce the evidence above.
  • Agent exposed in the Agent UI — N/A, no Agent UI surface changed.
  • MCP tools / servers — N/A, no MCP surface changed.

Checklist

  • I have linked a GitHub issue above (Closes #3360).
  • I have described why this change is being made, not just what changed.
  • I have run linting and tests locally (python util/lint.py --all, pytest tests/unit/).
  • I have attached real-world evidence matched to the surface I changed, or marked each surface N/A.
  • I have updated documentation if user-visible behavior changed — docs/spec/api-server.mdx (removed the workspace-root section and renumbered the flow) and docs/spec/browser-tools.mdx + the @tool docstring (the new overwrite refusal, so the model learns the rule instead of discovering it from an error).

Ovtcharov added 3 commits September 4, 2026 12:04
download_file took its filename from the remote Content-Disposition
header and opened the destination "wb" with no existence check, so a page
serving filename=report.pdf replaced the user's own ~/Downloads/report.pdf.
The sensitive-filename guardrail then ran after the write and reacted by
deleting the file -- and only when a _path_validator was attached, so an
agent composing BrowserToolsMixin without one kept the overwritten file.
The destination is now checked, and handed to the caller's policy screen,
before anything is created; the body streams to a sibling .part file that
is renamed into place only on success, so an interrupted download never
leaves a truncated file under the real name. A .part already in the way is
reported rather than deleted, since deleting it would destroy a concurrent
download's in-flight file.
Landing this before the SNI fix on purpose: most HTTPS downloads currently
fail the handshake and never reach the write path, so fixing SNI makes the
overwrite reachable.
The IP-pinning adapter closed the DNS-rebind window by rewriting the
request URL's host to the resolved IP and stashing the hostname in URL
userinfo, which urllib3 never reads for SNI. Every HTTPS request went out
with no SNI, so any host that selects its certificate by SNI returned the
wrong certificate or refused the handshake. Measured live, 9 of 11 hosts
failed through WebClient and succeeded through plain requests -- including
example.com, github.com, pypi.org, huggingface.co, and GAIA's own
amd-gaia.ai and lemonade-server.ai.
urllib3 lets the connect address and the TLS name be set independently, so
the pin and correct SNI are both achievable. build_connection_pool_key_
attributes -- the extension point requests documents for this -- now sets
server_hostname to the real hostname while the pool host stays the
validated IP. urllib3 uses server_hostname both as the ClientHello name and
as the name OpenSSL verifies the certificate against, and it is a PoolKey
field, so two hostnames resolving to one IP get separate pools instead of
racing on a shared one -- which the previous userinfo scheme did not
actually achieve, since requests drops userinfo when it builds the pool key.
assert_hostname is deliberately left unset. It would move name checking out
of the handshake into urllib3's post-hoc matcher by setting
check_hostname = False on the SSL context, which requests shares
process-wide on some versions.
The rebinding defence is unchanged: validate_url still rejects a hostname if
any answer is private, the adapter still re-validates the exact IP it dials,
and each redirect hop is still re-validated.
requests is pinned to >=2.32.3, the release that added the hook. 2.32.2 is
specifically unusable: it routes through get_connection_with_tls_context but
has no such hook, so SNI would silently revert to the pinned IP.
The live test is wired into the unit-tests workflow. It needs a real TLS
peer -- the adapter can pass every offline test and still fail every
handshake -- and skips itself when the runner has no network.
Every POST /v1/chat/completions returned HTTP 500, streaming or not. The
server passed workspace_root= to agent.process_query(); no agent accepts
it, so it reached Agent.process_query(user_input, max_steps, trace,
filename) and raised TypeError. The only consumer of that keyword was an
agent that has since been deleted, and nothing else reads it -- no agent
implements the set_workspace_root hook the spec described -- so the
extraction helper goes with it.
The suite stayed green because tests/test_api.py replaced the agent with a
MagicMock, which accepts any keyword: the mock proved the call happened,
never that it was valid. The new tests drive a real Agent subclass through
the same MemoryMixin -> Agent chain the shipped agents use, so a bad call
shape fails in CI. They fail against the previous code with the exact
TypeError above.
A Copilot <workspace_info> block is now ordinary message text, which is
what the model already treats it as.
@github-actions github-actions Bot added documentation Documentation changes dependencies Dependency updates devops DevOps/infrastructure changes tests Test changes agents labels Sep 4, 2026

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Verdict: Approve with suggestions

Three genuinely broken things get fixed here: the chat-completions endpoint that returned 500 on every request, HTTPS web fetch that failed on most of the web, and a download that could silently replace a file the user already had. All three fixes look right, and each ships with tests that fail against the old code.

One thing worth fixing before merge: the HTTPS fix quietly does nothing on an older requests. The new approach hangs off a hook that only exists in requests 2.32.3 and up. The dependency file now asks for that version, which covers fresh installs — but anyone who pulls this branch into an environment they already had gets no error, no warning, and the same broken handshakes the PR is fixing. This repo's own checkout is in exactly that state right now (it has 2.31.0), so it is not a hypothetical. A one-line version check at startup turns a silent revert into a message that tells the user to upgrade.

The rest are small: two docstrings came out of the edit with scrambled indentation, and two of the new live TLS tests will go red rather than skip if the third-party test site they depend on is unreachable.

Real-world evidence

No automated evidence bundle was produced for this run, so nothing was exercised on my side — the verdict rests on static review plus the evidence the author posted on the PR, which I am relaying rather than reproducing.

The PR description carries before/after curl output against a running gaia api (500 with the argument error → 200 with a correct answer, plus a working streaming run and a Copilot-style request), and a before/after probe of the web fetch across 11 real hosts showing 9 that failed only through the pinned client now succeeding. That matches the surfaces the change touches, and it supports the verdict.

Two gaps, neither blocking: the download-overwrite behaviour is covered by unit tests only, with no live run of the tool refusing to clobber a real file; and the agent eval that the tool-docstring change would normally require could not run because the judge account is out of credit. The author states this openly with the failure output rather than skipping it silently, which is the right handling — but the eval remains unrun.

🔍 Technical details

Issues

🟡 The SNI fix silently no-ops on requests < 2.32.3 (src/gaia/web/client.py:234)

build_connection_pool_key_attributes was only added in requests 2.32.3. On anything older the override is never called, server_hostname is never set, and SNI reverts to the pinned IP — the exact bug this PR fixes — with no error anywhere. Verified in this checkout:

$ python -c "import requests; from requests.adapters import HTTPAdapter; \
 print(requests.__version__, hasattr(HTTPAdapter,'build_connection_pool_key_attributes'))"
2.31.0 False

The setup.py pin protects a fresh pip install, but not a git pull into an existing venv — which is the common dev flow, and the state this repo is in today. CLAUDE.md's "No Silent Fallbacks — Fail Loudly" wants this to raise. Anchored at __init__ (client.py:143-145):

 def __init__(self, *args, **kwargs):
 # requests <2.32.3 lacks build_connection_pool_key_attributes, so the
 # SNI override below would never run and TLS would name the pinned IP.
 if not hasattr(HTTPAdapter, "build_connection_pool_key_attributes"):
 raise RuntimeError(
 "PinnedIPAdapter requires requests>=2.32.3 to set the TLS SNI "
 f"while pinning the IP; found {requests.__version__}. "
 "Run: pip install --upgrade 'requests>=2.32.3'"
 )
 super().__init__(*args, **kwargs)
 self._pinned_cache: Dict[Tuple[str, int], str] = {}

🟢 Two docstrings lost their indentation in the edit (client.py:101-141, client.py:700-722)

In the PinnedIPAdapter docstring the sentence introducing the pool arguments is split across a de-dented paragraph, so it reads as a fragment:

 pool arguments:
``server_hostname``, which
 urllib3 uses both as the ClientHello SNI ...

WebClient.download's docstring has the same problem — the summary line is flush while the whole body and Args: block sit at 16 spaces. Black does not reformat docstring bodies, so lint will not catch either. For the class docstring, joining the two halves fixes it (client.py:124-127):

 pool arguments. ``server_hostname`` is what urllib3 puts in the
 ClientHello SNI *and* the name OpenSSL verifies the certificate
 against, so a certificate valid only for the bare IP is never

🟢 Two live TLS tests fail instead of skipping when badssl.com is unreachable (tests/integration/test_web_client_live_sni.py:100-109)

test_certificate_name_is_still_verified and test_expired_certificate_is_still_rejected expect SSLError. If badssl.com is down, blocked, or slow, requests raises ConnectionError/Timeout instead, which escapes pytest.raises and turns an unrelated CI run red. The sibling tests already guard this with pytest.skip — worth applying the same treatment, e.g.:

def test_certificate_name_is_still_verified(web_client):
 """Pinning the IP must not disable certificate-name verification."""
 try:
 with pytest.raises(requests.exceptions.SSLError):
 web_client.get("https://wrong.host.badssl.com/", timeout=30)
 except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as exc:
 pytest.skip(f"badssl.com unreachable: {exc}")

🟢 A sentence in the download docstring reads as the opposite of the code (client.py:710-712)

The check is repeated immediately before the rename; a file created in that last instant by another local process is still replaced.

The code raises when save_path.exists() before os.replace, so a file that appears is not replaced — only one created inside the sub-microsecond window between that check and the rename is. Naming it as the residual TOCTOU window would read more clearly.

Verification notes

  • Confirmed workspace_root / set_workspace_root have no remaining references outside the new test's explanatory docstring, and no process_query in src/ or hub/ accepts the kwarg — so the removal is complete and the API had genuinely been 500-ing on every request.
  • Confirmed server_hostname is a urllib3 PoolKey field on both 1.26 and 2.x and survives _new_pool for the https scheme, so the same-IP-different-hosts pool separation the tests assert holds on both.
  • Confirmed browser_tools.py imports tool lazily inside register_browser_tools, so the new tests' patch("gaia.agents.base.tools.tool", ...) genuinely intercepts registration.
  • Confirmed pytest-timeout is installed in test_unit.yml, so the new step's --timeout=300 is valid.
  • Could not run the test suite — no pytest in this reviewer environment.

Strengths

  • The .part-file rewrite is the right shape. Exclusive open(..., "xb") means a concurrent download fails loudly instead of two writers interleaving, and cleanup only ever unlinks the file this call created — the easy bug here would have been unlinking on any failure and destroying someone else's in-flight download. The tests cover exactly that case.
  • Moving the sensitive-filename screen from after the write to before it closes a real hole: the old code reacted by deleting an already-written file, and only when a _path_validator happened to be attached.
  • TestRealAgentCallShape targets the actual failure mode. A MagicMock agent swallows any signature, which is why mock-based tests stayed green while every real request raised TypeError; binding against the real MemoryMixinAgent chain is what makes this a regression guard rather than another green mock.
  • Deliberately not setting assert_hostname — and saying why in the docstring — is the correct call; it would have flipped check_hostname = False on a context requests shares process-wide.
  • Three commits, one logical change each, conventional-commit titles, and the docs for both changed surfaces updated alongside the code.

The SNI fix hangs off build_connection_pool_key_attributes, which only
exists in requests >= 2.32.3. On anything older the override is never
called, server_hostname is never set, and TLS names the pinned IP again --
the exact bug this branch fixes, with no error anywhere. The setup.py pin
covers a fresh install but not a pull into an existing environment, which
is the common dev flow, so the pin was a wish rather than enforcement.
PinnedIPAdapter now refuses to construct without the hook and names the
installed version and the upgrade command. It probes for the attribute
rather than parsing a version because 2.32.2 is the awkward case: it routes
through get_connection_with_tls_context but ships no such hook, so a
">= 2.32" test would wave it through. Verified against real wheels --
2.31.0 and 2.32.2 refuse, 2.32.3 and 2.34.2 construct.
Also: the two live badssl.com checks now skip when the host is unreachable
instead of going red. "The site is down" and "certificate verification
regressed" must not look the same, or the lane teaches people to ignore it.
Docstrings: the PinnedIPAdapter and download docstrings had picked up
inconsistent indentation and a sentence split mid-clause; and the download
one claimed a file appearing before the rename "is still replaced", which
inverts what the code does -- it raises. Reworded to name the actual
residual window.

Copy link
Copy Markdown
Contributor Author

All four addressed in 87b9479c.

The silent no-op on an older requests — you were right that the pin was a wish, not enforcement. PinnedIPAdapter now refuses to construct without the hook, naming the installed version and the upgrade command. I probed for the attribute rather than parsing a version, because 2.32.2 is the case a >=2.32 test waves through — it routes via get_connection_with_tls_context but ships no hook. Checked against real wheels:

requests result
2.31.0 refused
2.32.2 refused
2.32.3 constructs
2.34.2 constructs

On 2.31.0, the version you have:

RuntimeError: PinnedIPAdapter needs requests>=2.32.3 to send the correct TLS SNI while
pinning the IP, but requests 2.31.0 is installed. Without it every HTTPS fetch names the
pinned IP in the handshake and CDN-fronted hosts reject the connection.
Upgrade with: pip install --upgrade 'requests>=2.32.3'

Two tests cover it: one deletes the attribute and asserts the error names the requirement, the installed version, and the fix; one asserts the adapter still constructs on the pinned floor.

The badssl tests — unreachable is now a skip, a handshake failure is still a failure, and a successful fetch is an explicit failure rather than silently passing. Worth noting for anyone touching that file: requests.exceptions.SSLError subclasses ConnectionError, so the except SSLError clause has to come first or the skip swallows the regression.

Docstrings — both reindented, the split sentence rejoined. You also caught that the download docstring said a file appearing before the rename "is still replaced", which inverts the code — it raises. Reworded to name the actual residual window (between the check and os.replace).

The download evidence gap — cheap, so I added it. Live run of the tool itself, not WebClient, into a directory already holding README.md. It is now in the PR description.

Before, the tool reports success while destroying the file:

>>> download_file("https://raw.githubusercontent.com/amd/gaia/main/README.md", save_to=...)
Downloaded: README.md
 Size: 7.3 KB
$ cat README.md
# <img src="https://raw.githubusercontent.com/amd/gaia/main/ <-- user's notes gone

After:

Error: Refusing to overwrite existing file: ...\README.md.
 Pass an explicit filename= to download under a different name.
$ cat README.md
MY OWN IMPORTANT NOTES <-- intact

A free name still works (filename="gaia-readme.md"Downloaded: gaia-readme.md, 7.3 KB).

The eval remains unrun for the reason in the description — the judge account is out of credit, not a backend problem.

Lint: Black / isort / Flake8 / Bandit pass; Pylint's 9 errors are pre-existing Windows os.killpg/geteuid false positives in files this PR does not touch. Tests on the touched surface: 212 passed, 22 skipped.

Copy link
×ばつ slower, and that scenario is the token-heaviest (8335→2083). The other three are *faster* in wall-clock despite the same handicap. The docstring addition is ~25 tokens on one tool description, 0.3% of that prompt. On my earlier claim that the eval was blocked by an out-of-credit account: **that diagnosis was wrong**, and worth recording because the real cause is a trap for anyone else running evals here. `runner.py:949` adds `--bare` whenever `ANTHROPIC_API_KEY` is non-empty, and `--bare` restricts auth to that key — OAuth is never consulted. `load_dotenv()` walks up parent directories, so a `.env` above the checkout injects a key into the process even when the shell has none. A stale key there silently overrode working subscription auth, and the failure surfaced as HTTP 400 `"Credit balance is too low"` — pointing at billing rather than at the override. Running with the variable explicitly empty restores OAuth. Two pre-existing harness defects fell out of that, both untouched by this PR and noted in the description for separate filing: the `--bare` auth override is a silent fallback of exactly the kind CLAUDE.md forbids, and the runner discards the judge subprocess body on failure (`"error": ""`), which is why the real cause stayed invisible. " data-view-component="true"> Copy Markdown
Contributor Author

The eval now has results — closing the one gap you named. Full numbers and caveats are in the PR description; the short version:

gaia eval agent --category tool_selection4/5 passed (80%), avg 8.7/10, compared against tests/fixtures/eval_baselines/gemma-4-e4b-d71cd914/scorecard_tool_selection.json (the Gemma-4-E4B baseline, matched by name).

Metric Baseline Current
Pass rate 75% 80%
Avg score 8.4 8.7

No status or score regressions, and the baseline's one failing scenario (known_path_read, 6.7) now passes at 9.6.

Three things I'd rather state than let the numbers imply:

  • The baseline was judged by claude-sonnet-4-6 and this run by claude-opus-5, so the per-scenario score deltas mix a judge change with behaviour. The compare tool warns about this. "No regression" is the trustworthy signal; the +2.5 on multi_step_plan is not.
  • data_vs_recall_disambiguation is not in the baseline at all, so 5 scenarios are being compared against 4. On the 4 overlapping ones it is 4/4 pass against the baseline's 3/4.
  • The compare tool exits non-zero on a time regression: smart_discovery 179s → 417s. That is hardware, not this change — the baseline machine sustained 109–115 tok/s where this one runs 38–45 tok/s, ×ばつ slower, and that scenario is the token-heaviest (8335→2083). The other three are faster in wall-clock despite the same handicap. The docstring addition is ~25 tokens on one tool description, 0.3% of that prompt.

On my earlier claim that the eval was blocked by an out-of-credit account: that diagnosis was wrong, and worth recording because the real cause is a trap for anyone else running evals here. runner.py:949 adds --bare whenever ANTHROPIC_API_KEY is non-empty, and --bare restricts auth to that key — OAuth is never consulted. load_dotenv() walks up parent directories, so a .env above the checkout injects a key into the process even when the shell has none. A stale key there silently overrode working subscription auth, and the failure surfaced as HTTP 400 "Credit balance is too low" — pointing at billing rather than at the override. Running with the variable explicitly empty restores OAuth.

Two pre-existing harness defects fell out of that, both untouched by this PR and noted in the description for separate filing: the --bare auth override is a silent fallback of exactly the kind CLAUDE.md forbids, and the runner discards the judge subprocess body on failure ("error": ""), which is why the real cause stayed invisible.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Reviewers

@kovtcharov-amd kovtcharov-amd Awaiting requested review from kovtcharov-amd kovtcharov-amd is a code owner

Assignees

No one assigned

Labels

agents dependencies Dependency updates devops DevOps/infrastructure changes documentation Documentation changes tests Test changes

Projects

None yet

Milestone

No milestone

Development

Successfully merging this pull request may close these issues.

gaia api returns 500 on every request; web fetch fails on most HTTPS sites; download overwrites existing files

1 participant

AltStyle によって変換されたページ (->オリジナル) /