-
Notifications
You must be signed in to change notification settings - Fork 263
feat(acp): secrets in agent_context — prompt awareness + subprocess injection #2984
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
823c9fe
feat(acp): inject secrets into ACP subprocess env via agent_context
10a23ce
test(acp): add test for empty string secret not injected into subproc…
openhands-agent 3930f9d
feat(acp): thread agent_context through ACPAgentSettings.create_agent()
272dda6
refactor(acp): drop agent_context from ACPAgentSettings
eac23d8
Merge branch 'main' into feat/acp-secrets-in-agent-context
simonrosenberg c9ec6b3
Merge branch 'main' into feat/acp-secrets-in-agent-context
simonrosenberg File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -211,13 +211,15 @@ def test_allows_agent_context_for_prompt_extensions(self, tmp_path): | |
|
|
||
| self._init_with_patches(agent, tmp_path) | ||
|
|
||
| def test_rejects_unsupported_agent_context_at_init(self, tmp_path): | ||
| def test_allows_agent_context_with_secrets(self, tmp_path): | ||
| """Secrets are now ACP-compatible: they are injected into the subprocess | ||
| env by _start_acp_server and advertised in the prompt via <CUSTOM_SECRETS>.""" | ||
| agent = ACPAgent( | ||
| acp_command=["echo"], | ||
| agent_context=AgentContext(secrets={"GITHUB_TOKEN": "ghp_secret"}), | ||
| ) | ||
| with pytest.raises(NotImplementedError, match="secrets"): | ||
| self._init_with_patches(agent, tmp_path) | ||
| # Should not raise | ||
| self._init_with_patches(agent, tmp_path) | ||
|
|
||
| def test_agent_context_to_acp_prompt_context(self): | ||
| context = AgentContext( | ||
|
|
@@ -261,11 +263,31 @@ def test_agent_context_to_acp_prompt_context_emits_datetime_by_default(self): | |
| assert prompt is not None | ||
| assert "<CURRENT_DATETIME>" in prompt | ||
|
|
||
| def test_agent_context_to_acp_prompt_context_rejects_secrets(self): | ||
| context = AgentContext(secrets={"GITHUB_TOKEN": "ghp_secret"}) | ||
| def test_agent_context_to_acp_prompt_context_includes_secrets(self): | ||
| """Secrets appear in the ACP prompt as a <CUSTOM_SECRETS> block so the | ||
| ACP subprocess knows which environment variables are available.""" | ||
| from pydantic import SecretStr | ||
|
|
||
| from openhands.sdk.secret import StaticSecret | ||
|
|
||
| context = AgentContext( | ||
| secrets={ | ||
| "GITHUB_TOKEN": StaticSecret( | ||
| value=SecretStr("ghp_secret"), | ||
| description="GitHub authentication token", | ||
| ), | ||
| "MY_API_KEY": StaticSecret(value=SecretStr("key123")), | ||
| }, | ||
| current_datetime=None, | ||
| ) | ||
|
|
||
| with pytest.raises(NotImplementedError, match="secrets"): | ||
| context.to_acp_prompt_context() | ||
| prompt = context.to_acp_prompt_context() | ||
|
|
||
| assert prompt is not None | ||
| assert "<CUSTOM_SECRETS>" in prompt | ||
| assert "$GITHUB_TOKEN" in prompt | ||
| assert "GitHub authentication token" in prompt | ||
| assert "$MY_API_KEY" in prompt | ||
|
Comment on lines
+284
to
+290
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. NIT: would be great to add a couple asserts to check that the actual secret value is |
||
|
|
||
| def test_agent_context_to_acp_prompt_context_includes_legacy_repo_skills(self): | ||
| context = AgentContext( | ||
|
|
@@ -3291,3 +3313,144 @@ def test_roundtrip_via_conversation_state_persistence(self, tmp_path): | |
| assert kwargs["cwd"] == str(workspace) | ||
| conn2.new_session.assert_not_awaited() | ||
| assert agent2._session_id == "roundtrip-sess" | ||
|
|
||
|
|
||
| class TestACPSecretsEnvInjection: | ||
| """Tests for secret injection into the ACP subprocess environment. | ||
|
|
||
| Secrets passed via ``agent_context.secrets`` must land in the subprocess | ||
| env so the ACP server (Claude Code, Codex CLI, etc.) can use them. | ||
| ``acp_env`` entries take precedence over agent_context secrets. | ||
| """ | ||
|
|
||
| @staticmethod | ||
| def _make_conn(): | ||
| conn = MagicMock() | ||
| init_response = MagicMock() | ||
| init_response.agent_info = MagicMock() | ||
| init_response.agent_info.name = "claude-agent-acp" | ||
| init_response.agent_info.version = "1.0" | ||
| init_response.auth_methods = [] | ||
| conn.initialize = AsyncMock(return_value=init_response) | ||
| new_response = MagicMock() | ||
| new_response.session_id = "sess-1" | ||
| conn.new_session = AsyncMock(return_value=new_response) | ||
| conn.load_session = AsyncMock(return_value=MagicMock()) | ||
| conn.set_session_mode = AsyncMock() | ||
| conn.set_session_model = AsyncMock() | ||
| conn.authenticate = AsyncMock() | ||
| conn.close = AsyncMock() | ||
| return conn | ||
|
|
||
| @staticmethod | ||
| def _run_start_capturing_env(agent, tmp_path) -> dict: | ||
| """Run _start_acp_server and return the env dict passed to the subprocess.""" | ||
| from contextlib import ExitStack | ||
|
|
||
| from openhands.sdk.utils.async_executor import AsyncExecutor | ||
|
|
||
| captured: dict = {} | ||
| conn = TestACPSecretsEnvInjection._make_conn() | ||
|
|
||
| mock_process = MagicMock() | ||
| mock_process.stdin = MagicMock() | ||
| mock_process.stdout = MagicMock() | ||
|
|
||
| async def _fake_create_subprocess_exec(*_args, env=None, **_kwargs): | ||
| captured.update(env or {}) | ||
| return mock_process | ||
|
|
||
| async def _fake_filter(_src, _dst): | ||
| return None | ||
|
|
||
| state = _make_state(tmp_path) | ||
| agent._executor = AsyncExecutor() | ||
|
|
||
| with ExitStack() as stack: | ||
| stack.enter_context( | ||
| patch( | ||
| "openhands.sdk.agent.acp_agent.asyncio.create_subprocess_exec", | ||
| new=_fake_create_subprocess_exec, | ||
| ) | ||
| ) | ||
| stack.enter_context( | ||
| patch( | ||
| "openhands.sdk.agent.acp_agent.ClientSideConnection", | ||
| return_value=conn, | ||
| ) | ||
| ) | ||
| stack.enter_context( | ||
| patch( | ||
| "openhands.sdk.agent.acp_agent._filter_jsonrpc_lines", | ||
| new=_fake_filter, | ||
| ) | ||
| ) | ||
| stack.enter_context( | ||
| patch( | ||
| "openhands.sdk.agent.acp_agent.asyncio.StreamReader", | ||
| return_value=MagicMock(), | ||
| ) | ||
| ) | ||
| agent._start_acp_server(state) | ||
|
|
||
| return captured | ||
|
|
||
| def test_static_secret_injected_into_subprocess_env(self, tmp_path): | ||
| """A StaticSecret in agent_context.secrets lands in the subprocess env.""" | ||
| from pydantic import SecretStr | ||
|
|
||
| from openhands.sdk.secret import StaticSecret | ||
|
|
||
| agent = _make_agent( | ||
| agent_context=AgentContext( | ||
| secrets={ | ||
| "GITHUB_TOKEN": StaticSecret( | ||
| value=SecretStr("ghp_test123"), | ||
| description="GitHub token", | ||
| ) | ||
| } | ||
| ) | ||
| ) | ||
| env = self._run_start_capturing_env(agent, tmp_path) | ||
| assert env.get("GITHUB_TOKEN") == "ghp_test123" | ||
|
|
||
| def test_acp_env_takes_precedence_over_agent_context_secret(self, tmp_path): | ||
| """An explicit acp_env entry wins over the same key in agent_context.secrets.""" | ||
| from pydantic import SecretStr | ||
|
|
||
| from openhands.sdk.secret import StaticSecret | ||
|
|
||
| agent = _make_agent( | ||
| acp_env={"MY_TOKEN": "acp-env-wins"}, | ||
| agent_context=AgentContext( | ||
| secrets={"MY_TOKEN": StaticSecret(value=SecretStr("secret-panel"))} | ||
| ), | ||
| ) | ||
| env = self._run_start_capturing_env(agent, tmp_path) | ||
| assert env.get("MY_TOKEN") == "acp-env-wins" | ||
|
|
||
| def test_none_value_secret_not_injected(self, tmp_path): | ||
| """A StaticSecret with value=None is not added to the subprocess env.""" | ||
| from openhands.sdk.secret import StaticSecret | ||
|
|
||
| agent = _make_agent( | ||
| agent_context=AgentContext( | ||
| secrets={"ABSENT_SECRET": StaticSecret(value=None)} | ||
| ) | ||
| ) | ||
| env = self._run_start_capturing_env(agent, tmp_path) | ||
| assert "ABSENT_SECRET" not in env | ||
|
|
||
| def test_empty_string_secret_not_injected(self, tmp_path): | ||
| """Empty string secrets are not injected into the subprocess env.""" | ||
| from pydantic import SecretStr | ||
|
|
||
| from openhands.sdk.secret import StaticSecret | ||
|
|
||
| agent = _make_agent( | ||
| agent_context=AgentContext( | ||
| secrets={"EMPTY_SECRET": StaticSecret(value=SecretStr(""))} | ||
| ) | ||
| ) | ||
| env = self._run_start_capturing_env(agent, tmp_path) | ||
| assert "EMPTY_SECRET" not in env | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.