Skip to content

fix(sdk/python): stop probing cloud metadata / ipify when a callback URL is configured, add AGENTFIELD_DISABLE_IP_DETECTION - #969

Merged
AbirAbbas merged 5 commits into
mainfrom
triage/netdetect
Aug 26, 2026
Merged

fix(sdk/python): stop probing cloud metadata / ipify when a callback URL is configured, add AGENTFIELD_DISABLE_IP_DETECTION#969
AbirAbbas merged 5 commits into
mainfrom
triage/netdetect

Conversation

@AbirAbbas

@AbirAbbas AbirAbbas commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Summary

Constructing an Agent() inside a container made _build_callback_candidates() call _detect_container_ip(), which fires HTTP requests at 169.254.169.254, metadata.google.internal and https://api.ipify.org — even when the operator had already told the SDK exactly where the control plane should call back. On Kubernetes every agent start therefore produced a burst of NetworkPolicy deny entries for egress nobody asked for (#624).

This PR makes the probe run only when it can still contribute something: it is skipped when a callback URL is already configured (the callback_url constructor argument or AGENT_CALLBACK_URL), and a new AGENTFIELD_DISABLE_IP_DETECTION opt-out skips it unconditionally. With neither knob set the candidate list is exactly what it was before.

The diagnosis that _detect_container_ip() is the source of these requests was worked out earlier by @AmirF194 on #921; the implementation, tests and docs here are written independently.

Where the probe is actually reachable

_detect_container_ip() has exactly one caller, _build_callback_candidates(), and that function is reached from three places: _resolve_callback_url(), AgentFieldHandler.register_with_agentfield_server() and AgentFieldHandler.register_with_fast_lifecycle().

In a running agent today only the first of those fires, and only in one configuration:

  • Agent.__init__ resolves eagerly (_resolve_callback_url) only when the callback_url= argument is supplied — exactly the case this PR now skips the probe for.
  • With AGENT_CALLBACK_URL instead, or with neither, AgentServer.serve() builds base_url itself (it reads AGENT_CALLBACK_URL directly, else falls back to localhost) and never enters callback discovery. ConnectionManager.connect() then registers with that base_url.
  • register_with_agentfield_server() has no production caller (it is used by the SDK's own tests and functional harnesses); register_with_fast_lifecycle() has no caller at all.

Two consequences worth being explicit about, because an earlier revision of this description overstated them:

  1. The one live path that reached the metadata probe was the path where the answer was already known. That makes the primary fix strictly worth having, and it is what the live A/B below measures.
  2. AGENTFIELD_DISABLE_IP_DETECTION is defence in depth on a stock agent rather than an observable behaviour change: with no callback URL, a stock agent does not reach the probe with or without the flag. The flag gates the probe at its single call site, so it holds for code that calls the discovery helpers directly and it keeps holding if startup is ever re-wired through them. The docs entry in this PR now says that plainly instead of implying that unflagged clusters are probing.

This reachability gap is pre-existing and identical on main; this PR neither causes nor worsens it, and does not try to fix it.

What is actually lost when an explicit callback URL is set

Worth stating plainly, because "we stopped collecting a candidate" deserves scrutiny:

  • On the SDK side, _build_callback_candidates() returns a prioritized list and _resolve_callback_url() takes candidates[0]. A configured callback URL is added first (constructor argument ahead of AGENT_CALLBACK_URL), so the detected public IP was always strictly behind it.
  • On the control-plane side, when a registration does carry discovery metadata, gatherCallbackCandidates()resolveCallbackCandidates() (control-plane/internal/handlers/nodes_register.go) never probes candidates for reachability and always takes the first one: resolveCallbackCandidates() normalizes and de-duplicates the list, returns normalized[0], and its []types.CallbackTestResult return is always nil. The remaining candidates are persisted on CallbackDiscovery.Candidates for observability only and are never retried against.
  • A caveat on that paragraph, corrected from the earlier revision: the Python SDK only sends a discovery payload at all when callback_candidates is non-empty (_build_callback_discovery_payload() returns None otherwise), and callback_candidates is populated only by the two handler registration methods that have no production caller. So a stock agent registers with discovery unset, and the control plane short-circuits on the non-empty BaseURL. The python-sdk:auto path described above applies to callers that go through register_with_agentfield_server().

So no selection outcome changes: the public-IP candidate could only ever have been chosen when no explicit URL was configured, which is the case this PR leaves untouched. The concrete loss is that the persisted discovery metadata for an explicitly-configured agent no longer lists http://<detected-public-ip>:<port> as a diagnostic entry, and a hypothetical future control plane that walked the list would not see it. Every other candidate is kept: the Railway internal hostname, the local-network address, the container hostname, host.docker.internal and the localhost fallbacks.

A callback URL that fails to normalize (empty, whitespace-only, unparseable) is deliberately not treated as configured, so malformed input still falls back to full auto-detection rather than leaving the agent with no usable candidate.

What the opt-out does not do

AGENTFIELD_DISABLE_IP_DETECTION is not a network kill switch, and the docs entry now says so. It does not stop _detect_local_ip(), which opens a connectionless SOCK_DGRAM socket and connect()s toward 8.8.8.8:80 purely to read back the source address the kernel would use — no packets are sent, no DNS lookup happens. Registration and heartbeat traffic to the control plane is likewise unaffected.

Type of change

  • Bug fix
  • New feature
  • Refactor / cleanup
  • Docs only
  • Tests only
  • CI / tooling
  • Breaking change

Test plan

All commands run from sdk/python unless noted. Python SDK and docs/ are the only surfaces touched.

  • uvx --from ruff==0.15.22 ruff check .All checks passed!
  • uv run --extra dev ./scripts/run_pytest.sh -p no:cacheprovider (CI's literal entrypoint, coverage on) → 2023 passed, 4 skipped, 38 deselected, 37 warnings in 117.45s, exit 0; coverage TOTAL 2002 stmts, 116 miss, 94%
  • uv run --extra dev python -m pytest tests/test_agent_networking.py tests/test_agent_helpers.py tests/test_agent_bigfiles_final90.py tests/test_agent_field_handler.py -p no:cacheprovider --no-cov -q (every test file that touches callback discovery) → 69 passed
  • uvx --from ruff==0.15.22 ruff format --diff agentfield/agent.py → 23 hunks, all of them pre-existing drift below line 800; none of the lines this PR adds or edits report drift. The repo's Format (auto-fix) CI step is continue-on-error: true, and no untouched code was reformatted.
  • Coverage: pyproject.toml addopts (which scripts/coverage-surface.sh sdk-python reuses verbatim) measures agentfield.client, agent_field_handler, execution_context, execution_state, memory, rate_limiter, result_cacheagentfield.agent is not in the measured set, so this change cannot move the gated number in either direction.

Validation contract

Behaviours the change must exhibit, and the test that covers each. All live in sdk/python/tests/test_agent_networking.py unless noted. Every row is a statement about _build_callback_candidates() — see "Where the probe is actually reachable" above for which of them a stock agent exercises.

# Behaviour Test
1 In a container with no callback URL and no opt-out, _detect_container_ip() is called and its result is offered as a candidate — default behaviour unchanged test_container_ip_probe_runs_when_nothing_is_configured
2 With the callback_url constructor argument set, the probe is never called and the normalized explicit URL is candidates[0] test_container_ip_probe_skipped_for_explicit_callback_argument
3 With AGENT_CALLBACK_URL set, the probe is never called and the normalized env URL is candidates[0] test_container_ip_probe_skipped_for_callback_url_env_var
4 The constructor argument still outranks AGENT_CALLBACK_URL, and both suppress the probe test_explicit_callback_argument_is_preferred_over_env_var
5 AGENTFIELD_DISABLE_IP_DETECTION set to 1, true, TRUE, Yes or yes suppresses the probe with no callback URL configured test_disable_flag_truthy_values_skip_the_probe (parametrized)
6 "", 0, false, no, off, maybe are not opt-ins — the probe still runs test_disable_flag_non_truthy_values_leave_the_probe_enabled (parametrized)
7 A callback URL that normalizes to nothing (" ") is not "configured" and does not suppress the probe test_unusable_callback_url_still_allows_the_probe
8 Suppressing the probe removes no other candidate: Railway internal host, local IP, hostname, host.docker.internal and the localhost fallbacks are all still offered test_skipping_the_probe_keeps_every_other_candidate
9 The probe never runs outside a container, regardless of configuration test_probe_never_runs_outside_a_container
10 With detection off and nothing else resolvable, _resolve_callback_url() still returns a usable local URL rather than failing test_resolve_callback_url_with_detection_disabled_falls_back_locally
11 With a callback URL configured (constructor argument, AGENT_CALLBACK_URL, or the disable flag), building the candidate list issues no HTTP request — asserted against requests.get with _detect_container_ip left in place, not stubbed test_no_http_request_is_made_when_the_callback_url_is_known (parametrized over all three forms)
12 With none of them set, the probe really does reach requests.get, so the tripwire in #11 would fire if the skip regressed test_http_request_is_made_when_nothing_is_configured

Contract items 1–9 stub _detect_container_ip itself, which proves the helper is not called — a level above the literal symptom in #624. Items 11 and 12 close that gap by leaving the helper in place and putting a tripwire on requests.get, its only outbound entry point (it imports requests lazily and makes no other network call). The tripwire records each attempt in addition to raising AssertionError, because _detect_container_ip wraps every request in except Exception: pass and would otherwise swallow the raise — the recorded list is what gives the test teeth. Verified by reverting the fix locally: all three parameters of item 11 fail, and the recording shows all four probe targets (AWS, GCP, Azure, api.ipify.org) attempted with the AssertionError absorbed.

Two pre-existing tests asserted the behaviour this PR deliberately changes — that the detected public IP appears as a candidate while an explicit callback URL is set. Both were updated to assert the new contract (that the address is absent) rather than dropped, so the assertion still has teeth: test_build_callback_candidates_prefers_env and tests/test_agent_bigfiles_final90.py::test_callback_candidate_helpers. A monkeypatch.delenv("AGENTFIELD_DISABLE_IP_DETECTION") was added to those two and to tests/test_agent_helpers.py::test_resolve_callback_url_fallback_to_detected_ips so they stay hermetic on a machine that exports the new variable.

Verification round

An independent round re-tested the branch live and found no defect in the code; the follow-up commits are documentation-accuracy fixes only. What was exercised:

  • In-process network tripwire. requests.get, requests.Session.request, urllib.request.urlopen, socket.create_connection and socket.socket.connect/connect_ex are wrapped (recording, not blocking) before import agentfield; each case then runs in a fresh process with CONTAINER=1 so the in-container branch is live, constructs an Agent(...) and calls _build_callback_candidates(). Probe destinations counted per case, this branch vs main:

    case config probes during Agent.__init__ probes total on main
    a_kwarg callback_url="http://my-agent.svc:<port>" 0 0 10 during __init__, 20 total
    b_env AGENT_CALLBACK_URL set 0 0 10 total
    c_disable_true AGENTFIELD_DISABLE_IP_DETECTION=true 0 0 10 total
    d_neither nothing set 0 10 0 / 10 — identical

    d_neither is the control: default behaviour is unchanged, and the 10 recorded destinations are the four probe URLs (AWS, GCP, Azure, api.ipify.org) plus their socket-level connects. a_kwarg is the fix: 10 → 0 in the constructor, which is the only route into the probe a running agent takes. The 0 in d_neither's __init__ column on both branches is the reachability gap described above.

    In the suppressed cases the only non-local destination recorded is ('8.8.8.8', 80) — the _detect_local_ip() UDP connect(), unchanged by this PR and present on main too. That is why the docs entry now calls it out explicitly.

  • Candidate-list delta. Exactly one entry disappears in the skipped cases, the public-IP one, and it was never first in either build. Railway internal host, local-network address, container hostname, host.docker.internal and the localhost fallbacks are all still present.

  • Black-box A/B against a real control plane. A real agent process (app.serve()) behind a logging HTTP proxy, registering with an isolated control plane and executing a reasoner end to end. The proxy was first proven to log the probe URLs when they are requested. With main's SDK the proxy recorded 4 probe requests (169.254.169.254 ×2, metadata.google.internal, CONNECT api.ipify.org:443); with this branch's SDK, under identical env and the same explicit callback URL, it recorded 0. Both builds registered with the same callback URL, so on main the probe result was pure wasted egress. [Python SDK] Unexpected internal endpoints probe on start #624 reproduced in a real process, and fixed.

  • Negative control on the tests. The test file from this PR, run against main, fails 14 tests including all three parameters of contract item 11. The same 14 pass here.

  • No LLM or paid-API call was made at any point in this verification.

Test coverage

  • I ran tests for the surface(s) I changed locally.
  • New code paths are covered by tests in this PR (no bare additions).
  • If I removed code, I updated coverage-baseline.json in this PR only if the removal caused a legitimate regression and I called it out in the summary above. — n/a, no code removed and no baseline change.
  • The coverage gate check is green in CI before requesting review. — cannot be confirmed from a local run; see the coverage note in the test plan for why this change cannot move the sdk-python number.

Checklist

Related issues / PRs

Fixes #624
Refs #921 (earlier diagnosis of the same probe by @AmirF194)

AbirAbbas and others added 3 commits August 26, 2026 12:05
Constructing an Agent() inside a container made _build_callback_candidates()
call _detect_container_ip(), which fires HTTP requests at 169.254.169.254,
metadata.google.internal and https://api.ipify.org. It did so even when the
operator had already told the SDK where the control plane should call back,
so on Kubernetes every agent start produced a burst of NetworkPolicy deny
entries for egress nobody had asked for.

The probe now only runs when it can still contribute something:

- A callback URL configured through the `callback_url` constructor argument
  or AGENT_CALLBACK_URL suppresses it. Such a URL always normalizes to the
  first candidate and the control plane keeps the remaining candidates as
  fallbacks, so the detected public IP could never have been selected. A
  value that fails to normalize is not treated as configured, so malformed
  input still falls back to full auto-detection.
- AGENTFIELD_DISABLE_IP_DETECTION=1 (also `true`/`yes`, case-insensitive,
  surrounding whitespace tolerated) suppresses it unconditionally, for
  clusters that want the guarantee without pinning a URL.

Everything else is untouched: the Railway internal hostname, the local
network address, the container hostname, host.docker.internal and the
localhost fallbacks are still offered, the probe still never runs outside a
container, and with neither knob set the candidate list is what it was.

Fixes #624

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Records the new opt-out under the Python SDK agents section, along with the
fact that configuring a callback URL already suppresses the probe and which
callback candidates remain when it is off.

Refs #624

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… callback URL is configured

The existing opt-out tests stub `_detect_container_ip` itself, so they assert
that the helper is not called. The symptom reported in #624 is one level
lower: no HTTP request should leave the process. Those tests would still pass
if the probe grew a second outbound call outside the helper.

Add a test that leaves `_detect_container_ip` in place and puts a tripwire on
`requests.get` — the helper's only outbound entry point, since it imports
`requests` lazily and makes no other network call. It runs with
`_is_running_in_container` forced True and covers all three configured forms:
the `callback_url` argument, `AGENT_CALLBACK_URL`, and
`AGENTFIELD_DISABLE_IP_DETECTION`.

The tripwire records each attempt as well as raising `AssertionError`, because
`_detect_container_ip` wraps every request in `except Exception: pass` and
would swallow the raise. Verified against a locally reverted fix: all three
parameters fail, and the recorded list shows all four probe targets attempted
with the raise absorbed — so the recorded list, not the raise, is what gives
the test teeth.

A control test patches `requests.get` with a fake 200 response and asserts the
probe does reach it when nothing is configured, pinning down that the tripwire
would fire if the skip regressed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cursor

cursor Bot commented Aug 26, 2026

Copy link
Copy Markdown

gtg from my side

@github-actions

Copy link
Copy Markdown
Contributor

📊 Coverage gate

Thresholds from .coverage-gate.toml: per-surface ≥ 84%, aggregate ≥ 85%, max per-surface regression ≤ 1.0 pp, max aggregate regression ≤ 0.50 pp.

Surface Current Baseline Δ
control-plane 87.30% 87.40% ↓ -0.10 pp 🟡
sdk-go 92.90% 92.00% ↑ +0.90 pp 🟢
sdk-python 94.21% 93.73% ↑ +0.48 pp 🟢
sdk-typescript 91.39% 90.42% ↑ +0.97 pp 🟢
web-ui 84.76% 84.79% ↓ -0.03 pp 🟡
aggregate 85.70% 85.75% ↓ -0.05 pp 🟡

✅ Gate passed

No surface regressed past the allowed threshold and the aggregate stayed above the floor.

@github-actions

Copy link
Copy Markdown
Contributor

📐 Patch coverage gate

Threshold: 80% on lines this PR touches vs origin/main (from .coverage-gate.toml:thresholds.min_patch).

Surface Touched lines Patch coverage Status
control-plane 0 ➖ no changes
sdk-go 0 ➖ no changes
sdk-python 0 ➖ no changes
sdk-typescript 0 ➖ no changes
web-ui 0 ➖ no changes

✅ Patch gate passed

Every surface whose lines were touched by this PR has patch coverage at or above the threshold.

@github-actions

github-actions Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Performance

SDK Memory Δ Latency Δ Tests Status
Python 9.0 KB - 0.31 µs -11%

✓ No regressions detected

AbirAbbas and others added 2 commits August 26, 2026 16:22
The entry said the variable "is for clusters that want the guarantee
without pinning a URL", which overstates what it changes today. Reading
the SDK: _detect_container_ip() has exactly one caller
(_build_callback_candidates), and in a running agent that function is
only reached when the Agent was constructed with callback_url=... —
Agent.__init__ resolves the callback URL eagerly only in that case, and
AgentServer.serve() derives base_url itself (honouring AGENT_CALLBACK_URL
directly) without touching callback discovery. So a stock agent with no
callback URL never reaches the probe with or without the flag.

Rewrite the entry so every sentence holds: what it disables (the probe at
its single call site, container-only), when someone would actually set it
(a guarantee that survives however the agent is wired, or code calling
the discovery helpers directly), what it does NOT disable (the UDP
socket.connect() toward 8.8.8.8 used to read the local source address,
which sends no packets and does no DNS; registration and heartbeats are
unaffected), and what it costs (only the public-IP candidate).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three wording fixes, no behaviour change:

- _build_callback_candidates: say that _detect_container_ip is the only
  step that puts a request on the wire (the local-IP lookup opens a
  connectionless UDP socket and sends nothing), and that this function is
  the probe's only call site, which is why AGENTFIELD_DISABLE_IP_DETECTION
  suppresses it everywhere.
- _detect_container_ip: name the single caller instead of "callers are
  expected to", and mention the opt-out.
- Agent.__init__: the previous wording implied AGENT_CALLBACK_URL takes
  part in the constructor's resolution. It does not — __init__ resolves
  only when the callback_url argument is supplied. Say that, and describe
  AGENT_CALLBACK_URL's effect where it actually applies (any caller that
  goes through callback discovery).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AbirAbbas
AbirAbbas disabled auto-merge August 26, 2026 23:21
@AbirAbbas
AbirAbbas merged commit fe64d54 into main Aug 26, 2026
28 checks passed
@AbirAbbas
AbirAbbas deleted the triage/netdetect branch August 26, 2026 23:21
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.

[Python SDK] Unexpected internal endpoints probe on start

1 participant