Skip to content

feat(acp): type acp_env values as SecretStr to mask secrets in memory#3436

Open
simonrosenberg wants to merge 4 commits into
mainfrom
feat-acp-env-secretstr
Open

feat(acp): type acp_env values as SecretStr to mask secrets in memory#3436
simonrosenberg wants to merge 4 commits into
mainfrom
feat-acp-env-secretstr

Conversation

@simonrosenberg
Copy link
Copy Markdown
Member

@simonrosenberg simonrosenberg commented May 29, 2026

Closes #3430.

Problem

ACPAgent.acp_env and ACPAgentSettings.acp_env were typed dict[str, str]. Masking happened only at serialization time (the field_serializer wraps each value in SecretStr on the way out), so the in-memory values were plaintext and leaked through paths that bypass the serializer:

  • repr(agent) / print(agent) — pydantic renders the raw dict values
  • logger.info(f"...{agent.acp_env}") or any direct log of the dict
  • exception tracebacks that capture the model

Every other secret scalar in the SDK (LLM.api_key, StaticSecret.value, …) is SecretStr; acp_env was the odd one out.

Fix

Type the values as SecretStr so they're masked in memory. A shared alias keeps the wire/OpenAPI schema unchanged:

# pydantic_secrets.py
SecretEnvValue = Annotated[SecretStr, WithJsonSchema({"type": "string"})]

WithJsonSchema({"type": "string"}) pins the JSON/OpenAPI schema to a plain string instead of SecretStr's default format: password, writeOnly: true. That default would drop the field from response schemas (writeOnly) — a breaking REST change — and is also inaccurate here, since the value is returned (as a masked/encrypted string).

  • ACPAgent.acp_env / ACPAgentSettings.acp_envdict[str, SecretEnvValue]
  • serializers drop the now-redundant SecretStr(v) wrap
  • subprocess-spawn site unwraps via .get_secret_value()
  • resolve_acp_env() returns dict[str, SecretStr] (wraps provider-derived env)
  • decryption validators unchanged — the shared validate_secret_dict helper passes SecretStr through and coerces decrypted strSecretStr

No REST breakage (verified locally)

The generated agent-server OpenAPI for both acp_env-bearing schemas (ACPAgent-Input/-Output) is byte-identical to main:

acp_env -> {"additionalProperties": {"type": "string"}, ...}   # no writeOnly, no password format

Python API / REST API (OpenAPI) breakage checks should stay green.

Tradeoff to be aware of

Because dict is invariant in its value type, constructing with a dict[str, str] literal no longer type-checks (dict[str,str]dict[str,SecretStr]) — runtime still coerces via the before-validator. Callers either pass SecretStr values or go through create_agent() / model_validate. The OpenHands app builds the runtime agent via create_agent() (covered by resolve_acp_env); any direct ACPAgent(acp_env=...) construction there should wrap values in SecretStr (runtime works regardless). This is the inherent cost of in-memory masking for a dict field.

Test plan

uv run pytest over acp_agent / settings / settings_router / remote-request-logging / validation-sanitization / pydantic_secrets / agent_context — 433 passed. Added repr()-masking assertions to the redaction tests; existing cipher round-trip / plaintext-passthrough / undecryptable-warning tests updated to unwrap SecretStr.


Agent Server images for this PR

GHCR package: https://github.com/OpenHands/agent-sdk/pkgs/container/agent-server

Variants & Base Images

Variant Architectures Base Image Docs / Tags
java amd64, arm64 eclipse-temurin:17-jdk Link
python amd64, arm64 nikolaik/python-nodejs:python3.13-nodejs22-slim Link
golang amd64, arm64 golang:1.21-bookworm Link

Pull (multi-arch manifest)

# Each variant is a multi-arch manifest supporting both amd64 and arm64
docker pull ghcr.io/openhands/agent-server:f0271ea-python

Run

docker run -it --rm \
  -p 8000:8000 \
  --name agent-server-f0271ea-python \
  ghcr.io/openhands/agent-server:f0271ea-python

All tags pushed for this build

ghcr.io/openhands/agent-server:f0271ea-golang-amd64
ghcr.io/openhands/agent-server:f0271ea1231b97ec12aa3d06ef5f53ee621ac61f-golang-amd64
ghcr.io/openhands/agent-server:feat-acp-env-secretstr-golang-amd64
ghcr.io/openhands/agent-server:f0271ea-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:f0271ea-golang-arm64
ghcr.io/openhands/agent-server:f0271ea1231b97ec12aa3d06ef5f53ee621ac61f-golang-arm64
ghcr.io/openhands/agent-server:feat-acp-env-secretstr-golang-arm64
ghcr.io/openhands/agent-server:f0271ea-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:f0271ea-java-amd64
ghcr.io/openhands/agent-server:f0271ea1231b97ec12aa3d06ef5f53ee621ac61f-java-amd64
ghcr.io/openhands/agent-server:feat-acp-env-secretstr-java-amd64
ghcr.io/openhands/agent-server:f0271ea-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:f0271ea-java-arm64
ghcr.io/openhands/agent-server:f0271ea1231b97ec12aa3d06ef5f53ee621ac61f-java-arm64
ghcr.io/openhands/agent-server:feat-acp-env-secretstr-java-arm64
ghcr.io/openhands/agent-server:f0271ea-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:f0271ea-python-amd64
ghcr.io/openhands/agent-server:f0271ea1231b97ec12aa3d06ef5f53ee621ac61f-python-amd64
ghcr.io/openhands/agent-server:feat-acp-env-secretstr-python-amd64
ghcr.io/openhands/agent-server:f0271ea-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-amd64
ghcr.io/openhands/agent-server:f0271ea-python-arm64
ghcr.io/openhands/agent-server:f0271ea1231b97ec12aa3d06ef5f53ee621ac61f-python-arm64
ghcr.io/openhands/agent-server:feat-acp-env-secretstr-python-arm64
ghcr.io/openhands/agent-server:f0271ea-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-arm64
ghcr.io/openhands/agent-server:f0271ea-golang
ghcr.io/openhands/agent-server:f0271ea1231b97ec12aa3d06ef5f53ee621ac61f-golang
ghcr.io/openhands/agent-server:feat-acp-env-secretstr-golang
ghcr.io/openhands/agent-server:f0271ea-golang_tag_1.21-bookworm
ghcr.io/openhands/agent-server:f0271ea-java
ghcr.io/openhands/agent-server:f0271ea1231b97ec12aa3d06ef5f53ee621ac61f-java
ghcr.io/openhands/agent-server:feat-acp-env-secretstr-java
ghcr.io/openhands/agent-server:f0271ea-eclipse-temurin_tag_17-jdk
ghcr.io/openhands/agent-server:f0271ea-python
ghcr.io/openhands/agent-server:f0271ea1231b97ec12aa3d06ef5f53ee621ac61f-python
ghcr.io/openhands/agent-server:feat-acp-env-secretstr-python
ghcr.io/openhands/agent-server:f0271ea-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim

About Multi-Architecture Support

  • Each variant tag (e.g., f0271ea-python) is a multi-arch manifest supporting both amd64 and arm64
  • Docker automatically pulls the correct architecture for your platform
  • Individual architecture tags (e.g., f0271ea-python-amd64) are also available if needed

Closes #3430.

`ACPAgent.acp_env` and `ACPAgentSettings.acp_env` held plaintext `str`
values, so masking happened only at serialization time — `repr(agent)`,
logs, and tracebacks leaked provider API keys. Type the values as
`SecretStr` so they're masked in memory too (the field_serializer already
handles redact/encrypt/expose on the wire).

To avoid a REST contract break, a shared `SecretEnvValue` alias pins the
JSON/OpenAPI schema to a plain `string` via `WithJsonSchema` instead of
SecretStr's default `format: password, writeOnly: true` (which would drop
the field from response schemas). Verified the generated agent-server
OpenAPI for `acp_env` is byte-identical to main (`additionalProperties:
{type: string}`, no writeOnly) — no oasdiff breakage.

- `ACPAgent.acp_env` / `ACPAgentSettings.acp_env`: `dict[str, SecretEnvValue]`
- serializers: drop the now-redundant `SecretStr(v)` wrap
- spawn site unwraps via `.get_secret_value()`
- `resolve_acp_env()` returns `dict[str, SecretStr]` (wraps provider env)
- decryption validators unchanged (the shared helper passes SecretStr through)

Note: because `dict` is invariant, constructing with a `dict[str, str]`
literal no longer type-checks (runtime still coerces). Callers pass
`SecretStr` values or go through `create_agent()`/`model_validate`. The
OpenHands app builds the runtime agent via `create_agent()` (covered);
any direct `ACPAgent(acp_env=...)` construction there should wrap values.

Test plan: 433 passed across acp_agent / settings / settings_router /
remote-logging / validation-sanitization / pydantic_secrets /
agent_context suites; repr-masking assertions added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 29, 2026

Python API breakage checks — ✅ PASSED

Result:PASSED

Action log

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 29, 2026

REST API breakage checks (OpenAPI) — ✅ PASSED

Result:PASSED

Action log

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 29, 2026

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-sdk/openhands/sdk/agent
   acp_agent.py93810289%486–488, 618–619, 652, 654, 658, 662, 670, 733–734, 739, 806, 1020, 1023–1024, 1041–1042, 1071, 1076, 1152, 1162, 1198–1201, 1459–1462, 1466–1468, 1471–1475, 1477, 1665, 1679–1680, 1683–1685, 1693, 1697, 1701–1702, 1708–1709, 1721–1722, 1725, 1759, 1763–1765, 1769–1770, 1802, 2021–2023, 2026–2027, 2067, 2207, 2211, 2219–2221, 2259–2260, 2263, 2271–2273, 2275, 2277, 2281, 2284, 2293–2295, 2297, 2333–2334, 2352–2355, 2358, 2362–2364, 2366, 2370–2371, 2576–2577
openhands-sdk/openhands/sdk/settings
   model.py5494791%86, 336, 346–349, 352, 365, 369, 375, 385, 391, 396, 586, 599, 610, 620, 624, 626, 628, 630, 632, 634, 636, 911, 913, 1205, 1274, 1313, 1340, 1376–1379, 1405, 1529, 1574, 1606, 1616, 1618, 1623, 1641, 1654, 1656, 1658, 1660, 1667
TOTAL29221659977% 

simonrosenberg pushed a commit that referenced this pull request May 29, 2026
The acp_env field is now dict[str, SecretStr]; update the
test_start_conversation_accepts_acp_agent_settings assertion to compare
plaintext via get_secret_value(). Missed in the initial sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
acp_env is now dict[str, SecretStr]; compare plaintext via
get_secret_value() in test_start_conversation_accepts_acp_agent_settings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@simonrosenberg simonrosenberg force-pushed the feat-acp-env-secretstr branch from 3548652 to c10daeb Compare May 29, 2026 14:49
acp_env is now dict[str, SecretStr]; compare plaintext via
get_secret_value() in test_start_conversation_accepts_acp_agent_settings,
in ruff-canonical formatting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@simonrosenberg simonrosenberg marked this pull request as ready for review May 29, 2026 14:53
Copy link
Copy Markdown
Collaborator

all-hands-bot commented May 29, 2026

Review complete.

This review was performed through OpenHands Cloud Automation. You can log in and view the conversation here.

@simonrosenberg simonrosenberg self-assigned this May 29, 2026
Copy link
Copy Markdown
Collaborator

@all-hands-bot all-hands-bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: feat(acp): type acp_env values as SecretStr to mask secrets in memory

Overall Assessment: ✅ Looks good — clean, well-scoped security improvement with excellent test coverage.

Summary

This PR correctly fixes a real security gap: acp_env dict values were stored as plain str in memory, leaking through repr(), log-string interpolation, and exception tracebacks. Typing them as SecretStr puts acp_env on the same footing as every other secret scalar in the SDK (LLM.api_key, StaticSecret.value, etc.).

The implementation is technically sound:

  • SecretEnvValue = Annotated[SecretStr, WithJsonSchema({"type": "string"})] sidesteps the OpenAPI schema breakage that bare SecretStr would cause (writeOnly: true drops fields from response schemas — a REST-breaking change). Pinning the schema to {"type": "string"} preserves the existing wire format exactly.
  • validate_secret_dict already handles both str → SecretStr coercion and SecretStr-passthrough, so the existing cipher round-trip / plaintext-passthrough / undecryptable-warning paths work unchanged.
  • All three use-sites are updated consistently: field type declarations, field_serializers (redundant SecretStr(v) wrap removed), and the subprocess spawn site (.get_secret_value() unwrapping added).
  • resolve_acp_env() wraps provider-derived str values in SecretStr, keeping the returned dict uniformly masked.
  • repr-masking assertions added to redaction tests — good regression guard.

The PR description is unusually thorough: problem statement, fix rationale, OpenAPI non-regression evidence, and tradeoff acknowledgement all make this easy to review with confidence.

Minor observations

  1. _secret_env helper only in test_acp_agent.py: test_settings.py uses inline SecretStr(...) instead. Mild inconsistency — not a correctness issue, but worth harmonising if the pattern grows.
  2. _serialize_acp_env type annotation uses SecretStr not SecretEnvValue: Since SecretEnvValue = Annotated[SecretStr, ...], the runtime type is SecretStr, so this is correct. Using SecretEnvValue in the serializer signatures would add documentation consistency, but it is a very minor point.
  3. resolve_acp_env() merge: {k: SecretStr(v) for k, v in self.resolve_provider_env().items()}resolve_provider_env() returns dict[str, str] so there is no double-wrapping risk. Clear and correct.

This review was generated by an AI agent (OpenHands) on behalf of the user through OpenHands Automation. View conversation

# schemas (writeOnly) and is a breaking REST change; it's also inaccurate
# here, since the value really is returned as a (masked) string. Validation
# is unaffected — plain strings are still coerced to ``SecretStr``.
SecretEnvValue = Annotated[SecretStr, WithJsonSchema({"type": "string"})]
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well-designed alias. Using WithJsonSchema({"type": "string"}) to override pydantic's SecretStr defaults (format: password, writeOnly: true) is exactly the right approach — it avoids the REST breaking change while keeping full in-memory masking.

Worth noting for future use: if any other dict[str, str]-style secret maps are added to the SDK (e.g., MCP env headers, other subprocess envs), SecretEnvValue is ready to reuse from here rather than re-inventing the pattern.

env = default_environment()
env.update(os.environ)
env.update(self.acp_env)
env.update({k: v.get_secret_value() for k, v in self.acp_env.items()})
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct. This is the single 'exit point' where the SecretStr shell is shed. By keeping values masked as SecretStr from field construction all the way through to here, all earlier paths — serialization, repr, logging, tracebacks — are protected. .get_secret_value() should only appear at the subprocess boundary, which is exactly what this does.

# ---------------------------------------------------------------------------


def _secret_env(values: dict[str, str]) -> dict[str, SecretStr]:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice helper. One nit: test_settings.py builds acp_env with inline SecretStr(...) rather than using _secret_env. If more test files start constructing acp_env dicts, consider moving _secret_env (or an equivalent) to a shared conftest.py. At the current scope it is fine.

Copy link
Copy Markdown
Collaborator

all-hands-bot commented May 29, 2026

Review complete.

This review was performed through OpenHands Cloud Automation. You can log in and view the conversation here.

Copy link
Copy Markdown
Collaborator

@all-hands-bot all-hands-bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This PR correctly fixes a real security gap: acp_env values were stored as plaintext str in memory, leaking through repr(), log-string interpolation, and exception tracebacks. The implementation is clean and consistent with the SDK's existing secret-handling patterns.

The existing review already covers the technical mechanics well. I want to highlight two additional points:


⚠️ resolve_acp_env() is a behavioral public API change — caller adaptation required

resolve_acp_env() previously returned dict[str, str] (values ready for subprocess use). It now returns dict[str, SecretStr]. This is the correct design decision, but direct callers must adapt.

Failure mode to be aware of: In Python, str(SecretStr("my-api-key")) evaluates to **********not the actual value. So if a caller does:

env = os.environ.copy()
env.update(settings.resolve_acp_env())  # SecretStr objects silently inserted
subprocess.run(cmd, env=env)            # subprocess receives "**********" instead of real secrets

This fails silently — the subprocess receives masked values and likely crashes or misbehaves with no obvious indication of what went wrong.

The updated docstring documents the new return type, and the correct adaptation is:

env.update({k: v.get_secret_value() for k, v in settings.resolve_acp_env().items()})

Recommend adding a note to the CHANGELOG / migration guide flagging this as a behavioral change for any callers using resolve_acp_env() directly outside of create_agent().


✅ Plain-string construction of acp_env remains backward-compatible

The PR updates test literals to use SecretStr(...) explicitly:

acp_env={"OPENAI_API_KEY": SecretStr("sk-real-secret")}

Worth noting (and perhaps documenting) that plain strings still work:

acp_env={"OPENAI_API_KEY": "sk-real-secret"}  # also valid — Pydantic coerces str → SecretStr

Pydantic v2 coerces plain str to SecretStr for SecretStr-typed fields, and validate_secret_dict explicitly handles the coercion path too. So the construction API is backward-compatible — only the stored type changes. The explicit SecretStr(...) in tests is a clear stylistic choice, but users should know they don't have to change their existing acp_env dict construction.


Overall

The core fix is correct and well-executed. The SecretEnvValue alias with WithJsonSchema({"type": "string"}) is exactly the right approach to avoid REST schema breakage. The subprocess unwrap site is the correct single exit point. Tests are comprehensive. ✅

This review was generated by an AI agent (OpenHands) on behalf of the user through OpenHands Automation. View conversation

return env

def resolve_acp_env(self) -> dict[str, str]:
def resolve_acp_env(self) -> dict[str, SecretStr]:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heads-up — public API behavioral change: Prior to this PR, resolve_acp_env() returned dict[str, str] (values ready to pass directly to subprocess.Popen(env=...) or os.environ.update()). It now returns dict[str, SecretStr].

Any caller doing:

env.update(settings.resolve_acp_env())

will silently inject SecretStr objects instead of plain strings. When converted to string (e.g., via os.environ.update() or f-string interpolation), str(SecretStr("...")) evaluates to "**********" — a masked value — which would cause the subprocess to receive masked values silently instead of the real secrets.

The updated docstring documents this correctly. Consider adding a CHANGELOG/migration note that direct callers need to adapt:

env.update({k: v.get_secret_value() for k, v in settings.resolve_acp_env().items()})

Copy link
Copy Markdown
Collaborator

@all-hands-bot all-hands-bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ QA Report: PASS

The ACP env secret-masking behavior works as described: PR values are masked in memory while serialization, encrypted round-trip, subprocess env injection, and OpenAPI shape remain functional.

Does this PR achieve its stated goal?

Yes. On main, constructing ACPAgent / ACPAgentSettings with a synthetic acp_env secret leaked the value through repr(...) and repr(agent.acp_env); on f0271ea1231b97ec12aa3d06ef5f53ee621ac61f, the same SDK usage stores values as SecretStr and those repr paths no longer expose the plaintext. I also verified that exposed/encrypted serialization still preserves the value when requested, create_agent() still carries the resolved env, the ACP subprocess startup path receives the plaintext env value, and the real /openapi.json response keeps acp_env as a plain string map.

Phase Result
Environment Setup make build completed successfully with uv sync --dev.
CI Status ✅ Product/CI checks observed green; one qa-changes automation run was still in progress and an earlier one was cancelled.
Functional Verification ✅ Real SDK construction/serialization and agent-server OpenAPI behavior verified before/after.
Functional Verification

Test 1: SDK ACP env masking and serialization

Step 1 — Reproduce / establish baseline (without the fix):
Ran git switch --detach origin/main && uv run python /tmp/qa_acp_secret_behavior.py with a synthetic OPENAI_API_KEY in acp_env:

{
  "agent_env_value_type": "str",
  "settings_env_value_type": "str",
  "resolved_env_value_type": "str",
  "agent_repr_leaks_secret": true,
  "agent_env_repr_leaks_secret": true,
  "settings_repr_leaks_secret": true,
  "agent_default_json_dump": {"OPENAI_API_KEY": "**********", "BASE_URL": "**********"},
  "agent_exposed_json_dump": {"OPENAI_API_KEY": "sk-qa-secret-12345", "BASE_URL": "https://llm-proxy.example.test"},
  "encrypted_payload_contains_plaintext": false,
  "restored_env_revealed": {"OPENAI_API_KEY": "sk-qa-secret-12345"}
}

This confirms the reported bug: serialization already redacted/encrypted, but in-memory repr/direct-dict paths exposed the plaintext because values were plain str.

Step 2 — Apply the PR's changes:
Checked out f0271ea1231b97ec12aa3d06ef5f53ee621ac61f.

Step 3 — Re-run with the fix in place:
Ran uv run python /tmp/qa_acp_secret_behavior.py:

{
  "agent_env_value_type": "SecretStr",
  "settings_env_value_type": "SecretStr",
  "resolved_env_value_type": "SecretStr",
  "created_agent_env_value_type": "SecretStr",
  "agent_repr_leaks_secret": false,
  "agent_env_repr_leaks_secret": false,
  "settings_repr_leaks_secret": false,
  "agent_default_json_dump": {"OPENAI_API_KEY": "**********", "BASE_URL": "**********"},
  "agent_exposed_json_dump": {"OPENAI_API_KEY": "sk-qa-secret-12345", "BASE_URL": "https://llm-proxy.example.test"},
  "created_agent_env_revealed": {"OPENAI_API_KEY": "sk-qa-secret-12345"},
  "encrypted_payload_contains_plaintext": false,
  "restored_env_revealed": {"OPENAI_API_KEY": "sk-qa-secret-12345"}
}

This shows the fix works for the stated goal: the same user-level construction path now masks in-memory repr output while preserving value access for explicit expose/encryption and agent creation flows.

Test 2: REST/OpenAPI compatibility through a real server

Step 1 — Establish baseline:
On main, started the actual server with uv run agent-server --host 127.0.0.1 --port 18081 and queried curl http://127.0.0.1:18081/openapi.json for ACP schemas:

{
  "ACPAgent-Input": {"additionalProperties": {"type": "string"}, "description": "Additional environment variables for the ACP server process", "title": "Acp Env", "type": "object"},
  "ACPAgent-Output": {"additionalProperties": {"type": "string"}, "description": "Additional environment variables for the ACP server process", "title": "Acp Env", "type": "object"}
}

This establishes the previous REST contract: acp_env is a string-valued object in both input and output schemas.

Step 2 — Apply the PR's changes:
Checked out f0271ea1231b97ec12aa3d06ef5f53ee621ac61f.

Step 3 — Re-run with the fix in place:
Started uv run agent-server --host 127.0.0.1 --port 18080 and queried the same /openapi.json path:

{
  "ACPAgent-Input": {"additionalProperties": {"type": "string"}, "description": "Additional environment variables for the ACP server process", "title": "Acp Env", "type": "object"},
  "ACPAgent-Output": {"additionalProperties": {"type": "string"}, "description": "Additional environment variables for the ACP server process", "title": "Acp Env", "type": "object"}
}

The schema is unchanged for the ACP env property, so the PR does not introduce the SecretStr writeOnly/password schema break it set out to avoid.

Test 3: ACP subprocess env injection

Step 1 — Exercise the changed runtime path:
On the PR commit, ran uv run python /tmp/qa_acp_subprocess_start.py, which constructs an ACPAgent with acp_env={"OPENAI_API_KEY": "sk-qa-subprocess-secret"} and starts a temporary child command that writes the received env to /tmp/qa_acp_subprocess_env.json.

Observed output/side effect:

{"OPENAI_API_KEY": "sk-qa-subprocess-secret"}

The temporary child was intentionally not a full ACP server, so the conversation initialization did not complete, but the subprocess started and received the plaintext env value. This specifically exercises the spawn/env handoff that must unwrap SecretStr before launching the ACP process.

Issues Found

None.

This review was created by an AI agent (OpenHands) on behalf of the user.

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.

Type acp_env values as SecretStr to mask secrets in memory/logs (not just on serialize)

2 participants