Skip to content

Commit 47ea375

Browse files
fix(restricted-network): allowlist an agent's own service host (#989)
A tbh trial on a restricted task failed at the first model call with 'failed to fetch model catalog: transport error ... https://api.meta.ai/ muse-code/models'. The allowlist held only api.ai.meta.com, derived from the meta/ model prefix, because host inference assumes an agent talks to the endpoint its model id names. That holds for every agent that calls the provider directly, but tbh fronts the model through Meta's own service, so its host follows from the AGENT rather than the model. Cursor has the same property and is handled by a cursor/ model prefix; tbh has no such prefix, since its models are ordinary meta/ ids. Adds an agent-keyed host map unioned into the single-container restricted-phase allowlist. A caller-supplied endpoint (base_url kwarg or TBH_BASE_URL) is added alongside the default rather than replacing it, so pointing at staging cannot silently un-allowlist a production fallback. Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent af27c80 commit 47ea375

3 files changed

Lines changed: 113 additions & 6 deletions

File tree

oddish/src/oddish/workers/harbor/model_hosts.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,15 @@
105105
# api5), and the installer is intentionally unpinned. Use Cursor's official
106106
# domain boundary instead of encoding ephemeral transport hostnames.
107107
_CURSOR_RUNTIME_HOSTS = ("*.cursor.sh",)
108+
# tbh (published as ``muse``) reaches Meta through its own service rather than
109+
# the OpenAI-compatible model API, so its host does not follow from the model
110+
# id the way every other agent's does: a ``meta/`` model resolves
111+
# ``api.ai.meta.com`` while the harness dials ``api.meta.ai`` for its model
112+
# catalog and inference. Keyed on the AGENT, unlike the cursor arm below, which
113+
# is keyed on a ``cursor/`` model prefix.
114+
TBH_BASE_URL_KEYS = ("TBH_BASE_URL",)
115+
_TBH_RUNTIME_HOSTS = ("api.meta.ai",)
116+
_AGENT_RUNTIME_HOSTS: dict[str, tuple[str, ...]] = {"tbh": _TBH_RUNTIME_HOSTS}
108117

109118
_DEFAULT_BEDROCK_REGION = "us-east-1"
110119
_BEDROCK_STS_DOMAINS = ("sts.amazonaws.com",)
@@ -177,6 +186,50 @@ def _default_host(url: str) -> str | None:
177186
return _host_from_url(url)
178187

179188

189+
def agent_runtime_hosts(
190+
*,
191+
agent_name: str | None,
192+
import_path: str | None = None,
193+
agent_kwargs: Mapping[str, Any] | None = None,
194+
agent_env: Mapping[str, str] | None = None,
195+
) -> list[str]:
196+
"""Hosts an agent's OWN service needs, independent of the model provider.
197+
198+
Model-derived hosts cover every agent that talks to the provider endpoint
199+
the model id names. An agent that fronts its own service needs its host
200+
added on top, or a restricted trial's allowlist is missing the only host
201+
the harness actually dials. A caller-supplied endpoint (the ``base_url``
202+
kwarg or ``TBH_BASE_URL``) is added alongside rather than replacing the
203+
default, so pointing at a staging endpoint cannot silently drop the
204+
production one a fallback might still use.
205+
"""
206+
key = (agent_name or "").strip().lower()
207+
if not key and import_path:
208+
# Oddish wrappers null the name and set an import path instead.
209+
key = import_path.rsplit(":", 1)[-1].strip().lower()
210+
hosts = list(_AGENT_RUNTIME_HOSTS.get(key, ()))
211+
if not hosts:
212+
return []
213+
214+
override: Any = None
215+
if isinstance(agent_kwargs, Mapping):
216+
override = agent_kwargs.get("base_url")
217+
extra_env = agent_kwargs.get("extra_env")
218+
if not override and isinstance(extra_env, Mapping):
219+
override = next(
220+
(extra_env.get(k) for k in TBH_BASE_URL_KEYS if extra_env.get(k)), None
221+
)
222+
if not override and isinstance(agent_env, Mapping):
223+
override = next(
224+
(agent_env.get(k) for k in TBH_BASE_URL_KEYS if agent_env.get(k)), None
225+
)
226+
if isinstance(override, str):
227+
host = _host_from_url(override)
228+
if host:
229+
hosts.append(host)
230+
return list(dict.fromkeys(hosts))
231+
232+
180233
def outbound_hosts_for_model(
181234
model_name: str | None,
182235
*,

oddish/src/oddish/workers/harbor/runner.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
from .model_hosts import (
6262
GEMINI_BASE_URL_KEYS,
6363
GEMINI_OAUTH_ENV_KEYS,
64+
agent_runtime_hosts,
6465
outbound_hosts_for_model,
6566
)
6667
from .redaction import redact_exact_text, redact_exact_value
@@ -974,11 +975,22 @@ def _inject_restricted_agent_model_hosts(
974975
if resolved_env:
975976
agent_kwargs["extra_env"] = resolved_env
976977
inferred_hosts = normalize_allowed_hosts(
977-
outbound_hosts_for_model(
978-
agent_config.model_name,
979-
agent_env=resolved_env,
980-
agent_kwargs=agent_kwargs,
981-
)
978+
[
979+
*outbound_hosts_for_model(
980+
agent_config.model_name,
981+
agent_env=resolved_env,
982+
agent_kwargs=agent_kwargs,
983+
),
984+
# An agent that fronts its own service dials a host the model id
985+
# does not name; without this the allowlist holds only the model
986+
# API and the harness cannot reach its own endpoint.
987+
*agent_runtime_hosts(
988+
agent_name=agent_config.name,
989+
import_path=agent_config.import_path,
990+
agent_kwargs=agent_kwargs,
991+
agent_env=resolved_env,
992+
),
993+
]
982994
)
983995
agent_config.extra_allowed_hosts = list(
984996
dict.fromkeys([*agent_config.extra_allowed_hosts, *inferred_hosts])

oddish/tests/test_model_hosts.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
from oddish.workers.harbor.model_hosts import outbound_hosts_for_model
1+
from oddish.workers.harbor.model_hosts import (
2+
agent_runtime_hosts,
3+
outbound_hosts_for_model,
4+
)
25

36

47
def test_outbound_hosts_follow_model_not_agent_env_shape():
@@ -98,3 +101,42 @@ def test_bare_classifier_only_provider_infers_host_opt_in_only():
98101
# path leaves it empty (no widening).
99102
assert outbound_hosts_for_model("grok-4", infer_bare_provider=True) == ["api.x.ai"]
100103
assert outbound_hosts_for_model("grok-4") == []
104+
105+
106+
def test_tbh_gets_its_own_service_host_which_the_model_id_never_names():
107+
# Regression: a meta/ model resolves the OpenAI-compatible model API
108+
# (api.ai.meta.com) while the tbh harness dials api.meta.ai for its model
109+
# catalog and inference. A trial allowlisted from the model id alone failed
110+
# with "failed to fetch model catalog: transport error".
111+
assert outbound_hosts_for_model("meta/striking_tomcat172") == ["api.ai.meta.com"]
112+
assert agent_runtime_hosts(agent_name="tbh") == ["api.meta.ai"]
113+
114+
115+
def test_agent_runtime_hosts_are_empty_for_provider_talking_agents():
116+
# Every other agent reaches the endpoint its model id names, so it must not
117+
# gain a host here.
118+
assert agent_runtime_hosts(agent_name="claude-code") == []
119+
assert agent_runtime_hosts(agent_name="codex") == []
120+
assert agent_runtime_hosts(agent_name=None) == []
121+
122+
123+
def test_a_custom_endpoint_is_added_alongside_the_default():
124+
# Added rather than substituted: a fallback to the production endpoint
125+
# must not be silently un-allowlisted by pointing at staging.
126+
assert agent_runtime_hosts(
127+
agent_name="tbh", agent_kwargs={"base_url": "https://staging.meta.ai/v1"}
128+
) == ["api.meta.ai", "staging.meta.ai"]
129+
assert agent_runtime_hosts(
130+
agent_name="tbh", agent_env={"TBH_BASE_URL": "https://staging.meta.ai"}
131+
) == ["api.meta.ai", "staging.meta.ai"]
132+
assert agent_runtime_hosts(
133+
agent_name="tbh",
134+
agent_kwargs={"extra_env": {"TBH_BASE_URL": "https://staging.meta.ai"}},
135+
) == ["api.meta.ai", "staging.meta.ai"]
136+
137+
138+
def test_an_oddish_wrapper_import_path_still_resolves_its_agent():
139+
# Wrappers null the name and set an import path instead.
140+
assert agent_runtime_hosts(
141+
agent_name=None, import_path="oddish.workers.agents.tbh:Tbh"
142+
) == ["api.meta.ai"]

0 commit comments

Comments
 (0)