Skip to content

Commit 45f0a62

Browse files
Simon Rosenbergclaude
andcommitted
Remove acp_env and _return_metrics deprecations (removed_in 1.29.0)
Both features reached their scheduled removal version in 1.29.0: - Drop the no-op _return_metrics/return_metrics parameter from LLM.{completion,acompletion,responses,aresponses}, RouterLLM.completion, and the TestLLM doubles. Metrics are always on LLMResponse.metrics. - Remove the acp_env field end-to-end: ACPAgentSettings and ACPAgent (field/validators/serializers), ACPAgentSettings.resolve_acp_env(), the ACP spawn-time env-injection/precedence logic, and the REDACT_ALL_VALUES_KEYS entry. Arbitrary ACP subprocess env vars now ride the conversation secrets channel. Updates tests, the v1 ACP persisted-settings golden fixture, the persisted-settings-compat generator, and docstrings/AGENTS.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a3acac5 commit 45f0a62

28 files changed

Lines changed: 246 additions & 1086 deletions

.github/scripts/check_persisted_settings_compat.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,6 @@ def emit(key: str, payload: dict[str, Any]) -> None:
145145
acp = ACPAgentSettingsCls(
146146
acp_server="claude-code",
147147
acp_model="claude-opus-4-6",
148-
acp_env={"OPENAI_API_KEY": "sk-test-acp"},
149148
)
150149
except Exception:
151150
acp = None

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ When reviewing code, provide constructive feedback:
142142
- Remote workspace git operations should call `/api/git/changes` and `/api/git/diff` via the `path` query parameter with slash-normalized strings; building those URLs with `pathlib.Path` leaks host-platform separators and breaks Windows paths. The grep tool now prefers `rg`, then system `grep`, then Python; both the real grep executor and the SDK's terminal-command compatibility fallback should keep that order. For grep parity, the Python fallback should hide dotfiles by default but still let explicit `include` globs surface files like `.env`, matching ripgrep. For glob parity, any symlink-preservation regression test should force the Python fallback path, because ripgrep availability changes whether the fallback implementation runs at all.
143143
- Keep path helpers split by purpose: `is_absolute_path_source()` is for cross-platform source/wire syntax detection, while local filesystem writes/validation (for example, the file editor) should use host-native absolute-path semantics so POSIX does not silently accept Windows drive paths as creatable files.
144144
- Tool availability filtering belongs in `openhands-sdk/openhands/sdk/tool/registry.py` via `list_usable_tools()`, which preserves registration order and defaults tools to usable unless they expose an `is_usable()` callable. Environment-specific checks like Chromium detection should live on the concrete tool class (`BrowserToolSet.is_usable()`), while agent-server surfaces such as `/server_info` should consume the registry helper rather than re-implement per-tool filtering.
145-
- Pydantic secret field helpers live in `openhands-sdk/openhands/sdk/utils/pydantic_secrets.py`. `serialize_secret()` handles serialization (cipher / `expose_secrets` / default Pydantic masking); `validate_secret()` handles deserialization (cipher decryption, redacted/empty → `None`); `is_redacted_secret()` checks for the sentinel; `REDACTED_SECRET_VALUE` is the canonical sentinel string. For `dict[str, str]` fields whose values are all secrets, wrap each value in `SecretStr` and call `serialize_secret` per value (see `LookupSecret._serialize_secrets` and `ACPAgent._serialize_acp_env`). Do not hand-roll redaction logic in field serializers.
145+
- Pydantic secret field helpers live in `openhands-sdk/openhands/sdk/utils/pydantic_secrets.py`. `serialize_secret()` handles serialization (cipher / `expose_secrets` / default Pydantic masking); `validate_secret()` handles deserialization (cipher decryption, redacted/empty → `None`); `is_redacted_secret()` checks for the sentinel; `REDACTED_SECRET_VALUE` is the canonical sentinel string. For `dict[str, str]` fields whose values are all secrets, wrap each value in `SecretStr` and call `serialize_secret` per value (see `LookupSecret._serialize_secrets`). Do not hand-roll redaction logic in field serializers.
146146

147147
- `LookupSecret` normalizes hostless URLs against `OH_INTERNAL_SERVER_URL` (set by `openhands-agent-server.__main__` from the bound host/port, rewriting wildcard binds to loopback) and otherwise falls back to `http://127.0.0.1:8000`, so relative secret URLs can safely target the current agent-server instance.
148148

openhands-agent-server/openhands/agent_server/api.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -424,7 +424,7 @@ def _sanitize_validation_errors(errors: Sequence[Any]) -> list[dict]:
424424
425425
FastAPI's default 422 response includes the raw request ``input`` in each
426426
validation error dict. If the request contained secret-bearing fields
427-
(e.g. ``agent.llm.api_key``, ``agent.acp_env``), those values would be
427+
(e.g. ``agent.llm.api_key``, MCP server ``env``), those values would be
428428
echoed back to the caller. This helper redacts them.
429429
430430
Args:
@@ -457,7 +457,7 @@ async def _validation_exception_handler(
457457
458458
FastAPI's default 422 handler echoes the raw request body inside the
459459
``detail[].input`` field. When the request contains secrets (e.g.
460-
``agent.llm.api_key``, ``agent.acp_env``), this would leak credentials
460+
``agent.llm.api_key``, MCP server ``env``), this would leak credentials
461461
in the error response. We intercept the error, redact secret-bearing
462462
fields, and return a safe 422 response.
463463

openhands-agent-server/openhands/agent_server/persistence/models.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,14 @@ def _deep_merge(
6969
- Nested dicts are merged recursively.
7070
- **Inside a nested map** a ``None`` value **removes** that key — the
7171
"unset" primitive a plain deep-merge lacks. It lets a
72-
``PATCH /api/settings`` diff delete a single map entry (one
73-
``acp_env`` / MCP ``env`` key) without round-tripping the whole map::
72+
``PATCH /api/settings`` diff delete a single map entry (one MCP
73+
``env`` / ``headers`` key) without round-tripping the whole map::
7474
75-
{"agent_settings_diff": {"acp_env": {"STALE_KEY": null}}}
75+
{"agent_settings_diff":
76+
{"mcp_config": {"mcpServers": {"svc": {"env": {"STALE_KEY": null}}}}}}
7677
77-
- **At the top level** (a settings *field* like ``confirmation_mode`` or
78-
``acp_env`` itself) a ``None`` is left as-is and flows to model
78+
- **At the top level** (a settings *field* like ``confirmation_mode``)
79+
a ``None`` is left as-is and flows to model
7980
validation — exactly as before this primitive existed. So a stray
8081
``{"confirmation_mode": null}`` still fails loudly (422) instead of
8182
silently resetting a field to its default. This scoping is deliberate:

openhands-agent-server/openhands/agent_server/settings_router.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -177,13 +177,9 @@ async def update_settings(
177177
The three ``*_settings_diff`` fields are deep-merged; nested objects merge
178178
recursively, and a ``null`` value **inside a nested map deletes that entry**
179179
— the "unset" primitive that lets a client remove a single map key without
180-
round-tripping the whole map. To drop one ACP env-var::
180+
round-tripping the whole map. To remove one MCP server's header::
181181
182182
PATCH /api/settings
183-
{"agent_settings_diff": {"acp_env": {"STALE_KEY": null}}}
184-
185-
or to remove one MCP server's header::
186-
187183
{"agent_settings_diff":
188184
{"mcp_config": {"mcpServers": {"svc": {"headers": {"X-Old": null}}}}}}
189185

openhands-sdk/openhands/sdk/agent/acp_agent.py

Lines changed: 25 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -83,11 +83,9 @@
8383
from openhands.sdk.tool import Tool # noqa: TC002
8484
from openhands.sdk.tool.builtins.finish import FinishAction, FinishObservation
8585
from openhands.sdk.utils import maybe_truncate
86-
from openhands.sdk.utils.deprecation import warn_deprecated
8786
from openhands.sdk.utils.pydantic_secrets import (
8887
serialize_secret,
8988
validate_secret,
90-
validate_secret_dict,
9189
)
9290

9391

@@ -1358,49 +1356,6 @@ class ACPAgent(AgentBase):
13581356
default_factory=list,
13591357
description="Additional arguments for the ACP server command",
13601358
)
1361-
acp_env: dict[str, str] = Field(
1362-
default_factory=dict,
1363-
description=(
1364-
"DEPRECATED (removed in 1.29.0): additional environment variables for "
1365-
"the ACP server process. Route subprocess env/credentials through "
1366-
"state.secret_registry (e.g. agent_context.secrets / "
1367-
"StartConversationRequest.secrets) instead."
1368-
),
1369-
)
1370-
1371-
@field_validator("acp_env", mode="before")
1372-
@classmethod
1373-
def _decrypt_acp_env_values(cls, value: Any, info: ValidationInfo) -> Any:
1374-
"""Decrypt persisted ACP environment values when a cipher is available.
1375-
1376-
Mirrors the settings-side ``_decrypt_acp_env_values`` on
1377-
:class:`openhands.sdk.settings.model.ACPAgentSettings`. The
1378-
settings variant handles the on-disk → memory round-trip,
1379-
but the conversation-start path goes
1380-
:class:`StartConversationRequest.agent_settings` → the request's
1381-
``_populate_agent_from_settings`` (a ``mode='before'``
1382-
model_validator that runs *without* cipher context) →
1383-
``settings.create_agent()`` → :class:`ACPAgent`. By the time
1384-
``conversation_service.start_conversation`` re-validates the full
1385-
:class:`StoredConversation` with the server's cipher in context,
1386-
the agent has already been constructed and its ``acp_env`` field
1387-
still holds ciphertext. Without a validator here, that ciphertext
1388-
survives the re-validation step and reaches the subprocess as the
1389-
env-var value — breaking any provider call that interprets the
1390-
variable (e.g. an Anthropic request reading a Fernet token in
1391-
place of ``ANTHROPIC_BASE_URL``).
1392-
1393-
Legacy plaintext values pass through unchanged so first writes
1394-
from clients that haven't gone through the encryption pipeline
1395-
still validate cleanly.
1396-
"""
1397-
return validate_secret_dict(value, info, description="ACP env")
1398-
1399-
@field_serializer("acp_env", when_used="always")
1400-
def _serialize_acp_env(self, value: dict[str, str], info):
1401-
"""Mask ``acp_env`` values via :func:`serialize_secret`."""
1402-
return {k: serialize_secret(SecretStr(v), info) for k, v in value.items()}
1403-
14041359
acp_session_mode: str | None = Field(
14051360
default=None,
14061361
description=(
@@ -2092,9 +2047,7 @@ def _isolate_acp_data_dir(
20922047
the per-conversation root is what isolation is for.
20932048
20942049
No-ops for an unrecognised command or a provider without a relocation
2095-
lever. An explicit ``acp_env`` pin of the data-dir var wins (it has the
2096-
highest precedence and is honoured as the materialisation target too), so
2097-
leave it untouched.
2050+
lever.
20982051
20992052
Claude note: relocating ``CLAUDE_CONFIG_DIR`` is safe under either auth
21002053
mode. :data:`_ENV_CONFLICT_MAP` is keyed on the OAuth token
@@ -2109,21 +2062,17 @@ def _isolate_acp_data_dir(
21092062
seen by anything the CLI subprocess itself spawns (``git``, ``npm``,
21102063
``node``, shells — e.g. ``~/.gitconfig``, ``~/.npmrc``, the npm cache).
21112064
That is accepted as the cost of isolating Gemini at all; callers that
2112-
need a narrower scope can pin ``HOME`` via ``acp_env`` (honoured below)
2113-
or leave isolation off for Gemini.
2065+
need a narrower scope can leave isolation off for Gemini.
21142066
2115-
Ordering: this runs *after* the ``secret_registry`` injection and the
2116-
``acp_env`` update in :meth:`_start_acp_server` so an ``acp_env`` pin of
2117-
the data-dir var is visible and wins. Relocation is now credential-blind
2067+
Ordering: this runs *after* the ``secret_registry`` injection in
2068+
:meth:`_start_acp_server`. Relocation is now credential-blind
21182069
(the auth-conflict strip is keyed on ``CLAUDE_CODE_OAUTH_TOKEN``, not on
21192070
the config dir), so the data-dir var it sets never affects auth.
21202071
"""
21212072
provider = detect_acp_provider_by_command(self.acp_command)
21222073
if provider is None or provider.data_dir_env_var is None:
21232074
return
21242075
env_var = provider.data_dir_env_var
2125-
if env_var in self.acp_env:
2126-
return
21272076
data_dir = self._acp_file_secret_dir(state, provider.key)
21282077
data_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
21292078
env[env_var] = str(data_dir)
@@ -2136,53 +2085,32 @@ def _materialise_file_secrets(
21362085
For each spec in :attr:`acp_file_secrets` whose secret is registered in
21372086
``state.secret_registry``, write its value to the spec's durable
21382087
per-conversation directory (:meth:`_acp_file_secret_dir`) and set the
2139-
controlling env var (``CODEX_HOME`` / ``GOOGLE_APPLICATION_CREDENTIALS``)
2140-
unless the caller pinned it via ``acp_env``.
2088+
controlling env var (``CODEX_HOME`` / ``GOOGLE_APPLICATION_CREDENTIALS``).
21412089
21422090
Seed-if-absent: a non-empty existing file is preserved, never clobbered
21432091
— so a token the CLI rewrites on refresh (Codex) survives a recycle, and
21442092
a stale pasted blob can't overwrite the live one. Files are ``0600`` in
21452093
``0700`` directories. The blob secret itself is not exported as an env
21462094
var (callers exclude it via :meth:`_present_file_secret_names`); only
21472095
the path env var is set.
2148-
2149-
If the caller pinned the data-dir env var via the (deprecated)
2150-
``acp_env``, the credential is seeded *where that pin points* so the file
2151-
and env stay consistent — and ``acp_env`` keeps its precedence over the
2152-
env var.
21532096
"""
21542097
for spec in self.acp_file_secrets:
21552098
name = spec.secret_name
21562099
value = state.secret_registry.get_secret_value(name)
21572100
if not value:
21582101
continue
2159-
# Seed where the data-dir env var will actually point: an explicit
2160-
# acp_env pin (which wins in env precedence) overrides the default
2161-
# per-conversation root, so honor it as the write target too.
2162-
pinned = self.acp_env.get(spec.env_var)
2163-
if pinned and spec.env_points_to == "dir":
2164-
directory = Path(pinned)
2165-
target = directory / spec.filename
2166-
elif pinned: # env_points_to == "file"
2167-
target = Path(pinned)
2168-
directory = target.parent
2169-
else:
2170-
directory = self._acp_file_secret_dir(state, spec.subdir)
2171-
target = directory / spec.filename
2102+
directory = self._acp_file_secret_dir(state, spec.subdir)
2103+
target = directory / spec.filename
21722104
try:
21732105
directory.mkdir(mode=0o700, parents=True, exist_ok=True)
21742106
# Tighten the SDK-owned per-conversation dir in case it
2175-
# pre-existed or umask widened mkdir's mode. Skip for an
2176-
# externally-pinned acp_env dir (e.g. a deliberately
2177-
# group-readable shared mount) so we don't silently narrow
2178-
# permissions the user chose.
2179-
if not pinned:
2180-
directory.chmod(0o700)
2181-
# Also clamp the shared SDK-owned `acp/` parent, which
2182-
# parents=True may have created under the process umask
2183-
# (e.g. 0o755); the leaf chmod above only covers <subdir>.
2184-
# Stop at `acp/` — its parent is the persistence layer's.
2185-
directory.parent.chmod(0o700)
2107+
# pre-existed or umask widened mkdir's mode.
2108+
directory.chmod(0o700)
2109+
# Also clamp the shared SDK-owned `acp/` parent, which
2110+
# parents=True may have created under the process umask
2111+
# (e.g. 0o755); the leaf chmod above only covers <subdir>.
2112+
# Stop at `acp/` — its parent is the persistence layer's.
2113+
directory.parent.chmod(0o700)
21862114
if target.is_file() and target.stat().st_size > 0:
21872115
# Seed-if-absent: keep the (possibly CLI-refreshed) contents,
21882116
# but still clamp perms — a pre-existing credential file may
@@ -2210,14 +2138,11 @@ def _materialise_file_secrets(
22102138
directory,
22112139
)
22122140
raise
2213-
# acp_env (applied last in _start_acp_server) keeps precedence; only
2214-
# set the env var here when the caller did not pin it.
2215-
if spec.env_var not in self.acp_env:
2216-
env[spec.env_var] = str(
2217-
directory if spec.env_points_to == "dir" else target
2218-
)
2141+
env[spec.env_var] = str(
2142+
directory if spec.env_points_to == "dir" else target
2143+
)
22192144
for companion in spec.warn_if_unset:
2220-
if not env.get(companion) and companion not in self.acp_env:
2145+
if not env.get(companion):
22212146
logger.warning(
22222147
"ACP file-secret %r materialised but %s is unset; the "
22232148
"provider may fail to authenticate until it is configured",
@@ -2239,12 +2164,11 @@ def _start_acp_server(self, state: ConversationState) -> None:
22392164
client.mask = state.secret_registry.mask_secrets_in_output
22402165

22412166
# Build the subprocess environment. Precedence, highest first:
2242-
# acp_env > state.secret_registry > os.environ > default_environment
2167+
# state.secret_registry > os.environ > default_environment
22432168
#
22442169
# Conversation credentials intentionally OVERRIDE ambient os.environ: an
22452170
# explicit per-conversation / provider secret must win over a same-named
2246-
# variable in the agent-server's own environment. acp_env (deprecated)
2247-
# stays highest.
2171+
# variable in the agent-server's own environment.
22482172
#
22492173
# agent_context.secrets are seeded into secret_registry at
22502174
# LocalConversation.__init__ (lower priority than request.secrets), so
@@ -2253,17 +2177,6 @@ def _start_acp_server(self, state: ConversationState) -> None:
22532177
env = default_environment()
22542178
env.update(os.environ)
22552179
_strip_inherited_npm_env(env)
2256-
if self.acp_env:
2257-
warn_deprecated(
2258-
"ACPAgent.acp_env",
2259-
deprecated_in="1.24.0",
2260-
removed_in="1.29.0",
2261-
details=(
2262-
"Route ACP subprocess env/credentials through "
2263-
"state.secret_registry (e.g. agent_context.secrets / "
2264-
"StartConversationRequest.secrets) instead."
2265-
),
2266-
)
22672180
# Reserved file-content credential secrets (Codex auth.json, Gemini
22682181
# Vertex SA — see _materialise_file_secrets) are written to disk, not
22692182
# injected as env vars, so exclude their (large blob) names from the
@@ -2272,28 +2185,20 @@ def _start_acp_server(self, state: ConversationState) -> None:
22722185
# Inject the whole registry: an ACP CLI is a black box we can't
22732186
# name-scan per command (unlike the regular agent's bash tool), so
22742187
# credentials must be delivered upfront. Registry values override
2275-
# ambient os.environ. Skip keys acp_env will set last (avoids a
2276-
# redundant LookupSecret.get_value()) and file secrets (materialised to
2277-
# disk below).
2188+
# ambient os.environ. Skip file secrets (materialised to disk below).
22782189
env.update(
2279-
state.secret_registry.get_all_secrets_as_env_vars(
2280-
exclude=set(self.acp_env) | file_secret_names
2281-
)
2190+
state.secret_registry.get_all_secrets_as_env_vars(exclude=file_secret_names)
22822191
)
22832192
# Materialise reserved file-content secrets to disk and point their
22842193
# data-dir env vars (CODEX_HOME / GOOGLE_APPLICATION_CREDENTIALS) at the
2285-
# written files. Done before acp_env so an explicit acp_env override of
2286-
# those vars still wins.
2194+
# written files.
22872195
self._materialise_file_secrets(state, env)
2288-
# acp_env (deprecated) has highest precedence.
2289-
env.update(self.acp_env)
22902196
# Strip CLAUDECODE so nested Claude Code instances don't refuse to start
22912197
env.pop("CLAUDECODE", None)
22922198

22932199
# Relocate the CLI's data/config root to a per-conversation directory so
22942200
# sandbox-sharing conversations don't race on a shared HOME (#1019).
2295-
# Runs after the registry injection and the acp_env update above so an
2296-
# acp_env pin of the data-dir var wins. Independent of the strip below
2201+
# Runs after the registry injection above. Independent of the strip below
22972202
# (keyed on the OAuth token, not the data-dir var), so ordering relative
22982203
# to it no longer matters for correctness.
22992204
if self.acp_isolate_data_dir:
@@ -2312,7 +2217,7 @@ def _start_acp_server(self, state: ConversationState) -> None:
23122217
# codex ignores OPENAI_BASE_URL; translate it into the config key it
23132218
# reads. Reads the *fully assembled* env above, so it fires regardless of
23142219
# which channel delivered OPENAI_BASE_URL (agent_context.secrets,
2315-
# state.secret_registry / StartConversationRequest.secrets, acp_env,
2220+
# state.secret_registry / StartConversationRequest.secrets,
23162221
# os.environ) — i.e. eval, canvas, and cloud all route the same way.
23172222
args += _codex_base_url_overrides(command, args, env)
23182223

0 commit comments

Comments
 (0)