From 3ef8b135fc1877c8a4b4c9f25ceb0a877966c7ba Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 3 Nov 2025 18:47:11 +0000 Subject: [PATCH 1/6] Add credential access documentation to execute_bash tool Document how the secret manager works with execute_bash tool to help agents understand how to access registered credentials in bash commands. The new section explains: - Automatic secret injection mechanism - How to reference secrets in commands (using environment variable syntax) - Case-insensitive secret detection - Automatic output masking for security Co-authored-by: openhands --- openhands-tools/openhands/tools/execute_bash/definition.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/openhands-tools/openhands/tools/execute_bash/definition.py b/openhands-tools/openhands/tools/execute_bash/definition.py index 575976c266..11bb2cbbe7 100644 --- a/openhands-tools/openhands/tools/execute_bash/definition.py +++ b/openhands-tools/openhands/tools/execute_bash/definition.py @@ -211,6 +211,12 @@ def visualize(self) -> Text: ### Output Handling * Output truncation: If the output exceeds a maximum length, it will be truncated before being returned. +### Credential Access +* Automatic secret injection: When you reference a registered secret key in your bash command, the secret value will be automatically exported as an environment variable before your command executes. +* How to use secrets: Simply reference the secret key in your command (e.g., `echo $GITHUB_TOKEN` or `curl -H "Authorization: Bearer $API_KEY" https://api.example.com`). The system will detect the key name in your command text and export it automatically. +* Secret detection: The system performs case-insensitive matching to find secret keys in your command text. If a registered secret key appears anywhere in your command, its value will be made available as an environment variable. +* Security: Secret values are automatically masked in command output to prevent accidental exposure. You will see `` instead of the actual secret value in the output. + ### Terminal Reset * Terminal reset: If the terminal becomes unresponsive, you can set the "reset" parameter to `true` to create a new terminal session. This will terminate the current session and start fresh. * Warning: Resetting the terminal will lose all previously set environment variables, working directory changes, and any running processes. Use this only when the terminal stops responding to commands. From 15a31d5dd74082742641c191753862c4bc92fe49 Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Tue, 4 Nov 2025 04:57:38 +0800 Subject: [PATCH 2/6] Update openhands-tools/openhands/tools/execute_bash/definition.py --- openhands-tools/openhands/tools/execute_bash/definition.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openhands-tools/openhands/tools/execute_bash/definition.py b/openhands-tools/openhands/tools/execute_bash/definition.py index beba477979..054c485dea 100644 --- a/openhands-tools/openhands/tools/execute_bash/definition.py +++ b/openhands-tools/openhands/tools/execute_bash/definition.py @@ -214,7 +214,7 @@ def visualize(self) -> Text: ### Credential Access * Automatic secret injection: When you reference a registered secret key in your bash command, the secret value will be automatically exported as an environment variable before your command executes. -* How to use secrets: Simply reference the secret key in your command (e.g., `echo $GITHUB_TOKEN` or `curl -H "Authorization: Bearer $API_KEY" https://api.example.com`). The system will detect the key name in your command text and export it automatically. +* How to use secrets: Simply reference the secret key in your command (e.g., `echo $GITHUB_TOKEN` or `curl -H "Authorization: Bearer $API_KEY" https://api.example.com`). The system will detect the key name in your command text and export it as environment variable before it executes your command. * Secret detection: The system performs case-insensitive matching to find secret keys in your command text. If a registered secret key appears anywhere in your command, its value will be made available as an environment variable. * Security: Secret values are automatically masked in command output to prevent accidental exposure. You will see `` instead of the actual secret value in the output. From 7b0374b08ddebfa8dbcc8822f30955007abf8bb4 Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 3 Nov 2025 21:10:21 +0000 Subject: [PATCH 3/6] Add available secrets to ExecuteBashObservation metadata This change enhances the execute_bash tool to show agents which secrets are available for use in bash commands. The available secret names are now populated in CmdOutputMetadata and displayed in the LLM-facing observation output. Changes: - Add available_secrets field to CmdOutputMetadata - Populate available_secrets from conversation's secret_registry - Display available secrets in ExecuteBashObservation.to_llm_content - Add test to verify the new functionality This helps agents discover what credentials they can access without having to guess or try different secret names. Co-authored-by: openhands --- .../tools/execute_bash/definition.py | 3 + .../openhands/tools/execute_bash/impl.py | 23 ++++--- .../openhands/tools/execute_bash/metadata.py | 6 ++ .../execute_bash/test_secrets_masking.py | 66 +++++++++++++++++++ 4 files changed, 90 insertions(+), 8 deletions(-) diff --git a/openhands-tools/openhands/tools/execute_bash/definition.py b/openhands-tools/openhands/tools/execute_bash/definition.py index 054c485dea..b2849bc66a 100644 --- a/openhands-tools/openhands/tools/execute_bash/definition.py +++ b/openhands-tools/openhands/tools/execute_bash/definition.py @@ -113,6 +113,9 @@ def to_llm_content(self) -> Sequence[TextContent | ImageContent]: ret += f"\n[Current working directory: {self.metadata.working_dir}]" if self.metadata.py_interpreter_path: ret += f"\n[Python interpreter: {self.metadata.py_interpreter_path}]" + if self.metadata.available_secrets: + secrets_list = ", ".join(f"${s}" for s in self.metadata.available_secrets) + ret += f"\n[Available secrets: {secrets_list}]" if self.metadata.exit_code != -1: ret += f"\n[Command finished with exit code {self.metadata.exit_code}]" if self.error: diff --git a/openhands-tools/openhands/tools/execute_bash/impl.py b/openhands-tools/openhands/tools/execute_bash/impl.py index 911e544ec5..f9cefe8f77 100644 --- a/openhands-tools/openhands/tools/execute_bash/impl.py +++ b/openhands-tools/openhands/tools/execute_bash/impl.py @@ -157,16 +157,23 @@ def __call__( self._export_envs(action, conversation) observation = self.session.execute(action) - # Apply automatic secrets masking - if observation.output and conversation is not None: + # Apply automatic secrets masking and populate available secrets + if conversation is not None: try: secret_registry = conversation.state.secret_registry - masked_output = secret_registry.mask_secrets_in_output( - observation.output - ) - if masked_output: - data = observation.model_dump(exclude={"output"}) - return ExecuteBashObservation(**data, output=masked_output) + + # Populate available secrets in metadata + available_secrets = list(secret_registry.secret_sources.keys()) + observation.metadata.available_secrets = available_secrets + + # Mask secrets in output if present + if observation.output: + masked_output = secret_registry.mask_secrets_in_output( + observation.output + ) + if masked_output: + data = observation.model_dump(exclude={"output"}) + return ExecuteBashObservation(**data, output=masked_output) except Exception: pass diff --git a/openhands-tools/openhands/tools/execute_bash/metadata.py b/openhands-tools/openhands/tools/execute_bash/metadata.py index a6f2e26701..cdc1b78942 100644 --- a/openhands-tools/openhands/tools/execute_bash/metadata.py +++ b/openhands-tools/openhands/tools/execute_bash/metadata.py @@ -38,6 +38,12 @@ class CmdOutputMetadata(BaseModel): py_interpreter_path: str | None = Field( default=None, description="The path to the current Python interpreter, if any." ) + available_secrets: list[str] = Field( + default_factory=list, + description=( + "List of available secret names that can be referenced in bash commands." + ), + ) prefix: str = Field(default="", description="Prefix to add to command output") suffix: str = Field(default="", description="Suffix to add to command output") diff --git a/tests/tools/execute_bash/test_secrets_masking.py b/tests/tools/execute_bash/test_secrets_masking.py index 2fa56d2777..f62f2fd99f 100644 --- a/tests/tools/execute_bash/test_secrets_masking.py +++ b/tests/tools/execute_bash/test_secrets_masking.py @@ -86,3 +86,69 @@ def test_bash_executor_with_conversation_secrets(): finally: executor.close() conversation.close() + + +def test_bash_executor_metadata_available_secrets(): + """Test that available secrets are populated in observation metadata.""" + with tempfile.TemporaryDirectory() as temp_dir: + # Create a conversation with secrets + llm = LLM( + model="gpt-4o-mini", api_key=SecretStr("test-key"), usage_id="test-llm" + ) + agent = Agent(llm=llm, tools=[]) + + test_secrets = { + "SECRET_TOKEN": "secret-value-123", + "API_KEY": "another-secret-456", + "DATABASE_URL": "postgres://localhost", + } + + conversation = Conversation( + agent=agent, + workspace=temp_dir, + persistence_dir=temp_dir, + secrets=test_secrets, + ) + + # Create executor + executor = BashExecutor(working_dir=temp_dir) + + try: + # Mock the session to avoid subprocess issues in tests + mock_session = Mock() + mock_observation = ExecuteBashObservation( + command="echo 'Test command'", + exit_code=0, + output="Test output", + ) + mock_session.execute.return_value = mock_observation + mock_session._closed = False + executor.session = mock_session + + # Execute command with conversation + action = ExecuteBashAction(command="echo 'Test command'") + result = executor(action, conversation=conversation) + + # Verify that available_secrets is populated in metadata + assert result.metadata.available_secrets is not None + assert len(result.metadata.available_secrets) == 3 + assert "SECRET_TOKEN" in result.metadata.available_secrets + assert "API_KEY" in result.metadata.available_secrets + assert "DATABASE_URL" in result.metadata.available_secrets + + # Verify that to_llm_content includes the available secrets + llm_content = result.to_llm_content + assert len(llm_content) == 1 + from openhands.sdk.llm import TextContent + + first_content = llm_content[0] + assert isinstance(first_content, TextContent) + content_text = first_content.text + assert "Available secrets:" in content_text + assert "$SECRET_TOKEN" in content_text + assert "$API_KEY" in content_text + assert "$DATABASE_URL" in content_text + + finally: + executor.close() + conversation.close() From 73ce7d06ed7f5eb678f3ab7d508c2b84a2ec4c25 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 11 Nov 2025 23:03:19 +0000 Subject: [PATCH 4/6] Add test for shell parameter expansion in secret detection Test verifies that shell parameter expansion syntax like ${GITHUB_TOKEN:0:8} is properly detected by the secret manager's find_secrets_in_text method. The test covers various parameter expansion forms: - ${var:offset:length} - substring expansion - ${var:-default} - default value if unset - ${var:?error} - error if unset - ${var#pattern} - remove shortest prefix match - ${var%pattern} - remove shortest suffix match This ensures agents can safely use these bash features with secrets. Co-authored-by: openhands --- .../sdk/conversation/test_secrets_manager.py | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/sdk/conversation/test_secrets_manager.py b/tests/sdk/conversation/test_secrets_manager.py index 6540704d2b..9291324df2 100644 --- a/tests/sdk/conversation/test_secrets_manager.py +++ b/tests/sdk/conversation/test_secrets_manager.py @@ -152,3 +152,45 @@ def get_value(self): # Only working secret should be returned assert env_vars == {"WORKING_SECRET": "working-value"} + + +def test_find_secrets_in_shell_parameter_expansion(): + """Test that find_secrets_in_text detects secrets in shell parameter expansion.""" # noqa: E501 + secret_registry = SecretRegistry() + secret_registry.update_secrets( + { + "GITHUB_TOKEN": "ghp_1234567890abcdef", + "API_KEY": "sk-test-key", + } + ) + + # Test various shell parameter expansion forms + # ${var:offset:length} - substring expansion + found = secret_registry.find_secrets_in_text("echo ${GITHUB_TOKEN:0:8}") + assert found == {"GITHUB_TOKEN"} + + # ${var:-default} - default value if unset + found = secret_registry.find_secrets_in_text("echo ${API_KEY:-default}") + assert found == {"API_KEY"} + + # ${var:?error} - error if unset + found = secret_registry.find_secrets_in_text("echo ${GITHUB_TOKEN:?token required}") + assert found == {"GITHUB_TOKEN"} + + # ${var#pattern} - remove shortest prefix match + found = secret_registry.find_secrets_in_text("echo ${API_KEY#sk-}") + assert found == {"API_KEY"} + + # ${var%pattern} - remove shortest suffix match + found = secret_registry.find_secrets_in_text("echo ${GITHUB_TOKEN%.txt}") + assert found == {"GITHUB_TOKEN"} + + # Multiple secrets with different expansion forms + found = secret_registry.find_secrets_in_text( + "echo ${GITHUB_TOKEN:0:8} ${API_KEY:-none}" + ) + assert found == {"GITHUB_TOKEN", "API_KEY"} + + # Case insensitive detection should still work + found = secret_registry.find_secrets_in_text("echo ${github_token:0:8}") + assert found == {"GITHUB_TOKEN"} From 40b77f755b9fd608d8a6fd454753ba21b9f3ba7a Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Wed, 12 Nov 2025 07:09:35 +0800 Subject: [PATCH 5/6] Update openhands-tools/openhands/tools/terminal/definition.py Co-authored-by: Engel Nyst --- openhands-tools/openhands/tools/terminal/definition.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openhands-tools/openhands/tools/terminal/definition.py b/openhands-tools/openhands/tools/terminal/definition.py index 979c15ae0a..4566028a19 100644 --- a/openhands-tools/openhands/tools/terminal/definition.py +++ b/openhands-tools/openhands/tools/terminal/definition.py @@ -218,7 +218,7 @@ def visualize(self) -> Text: ### Credential Access * Automatic secret injection: When you reference a registered secret key in your bash command, the secret value will be automatically exported as an environment variable before your command executes. -* How to use secrets: Simply reference the secret key in your command (e.g., `echo $GITHUB_TOKEN` or `curl -H "Authorization: Bearer $API_KEY" https://api.example.com`). The system will detect the key name in your command text and export it as environment variable before it executes your command. +* How to use secrets: Simply reference the secret key in your command (e.g., `echo ${GITHUB_TOKEN:0:8}` or `curl -H "Authorization: Bearer $API_KEY" https://api.example.com`). The system will detect the key name in your command text and export it as environment variable before it executes your command. * Secret detection: The system performs case-insensitive matching to find secret keys in your command text. If a registered secret key appears anywhere in your command, its value will be made available as an environment variable. * Security: Secret values are automatically masked in command output to prevent accidental exposure. You will see `` instead of the actual secret value in the output. From 6c630d556915274668a5393f89e9baebdcad960b Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 11 Nov 2025 23:14:02 +0000 Subject: [PATCH 6/6] Document periodic secret refresh and token expiration handling Add documentation explaining that secrets like GITHUB_TOKEN may be updated periodically or expire over time. When authentication failures occur, agents should try using the secret again in a new command to pick up the refreshed value. Include specific example of updating git remote URLs with refreshed GITHUB_TOKEN. Co-authored-by: openhands --- openhands-tools/openhands/tools/terminal/definition.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openhands-tools/openhands/tools/terminal/definition.py b/openhands-tools/openhands/tools/terminal/definition.py index 4566028a19..9004a4c0f1 100644 --- a/openhands-tools/openhands/tools/terminal/definition.py +++ b/openhands-tools/openhands/tools/terminal/definition.py @@ -221,6 +221,7 @@ def visualize(self) -> Text: * How to use secrets: Simply reference the secret key in your command (e.g., `echo ${GITHUB_TOKEN:0:8}` or `curl -H "Authorization: Bearer $API_KEY" https://api.example.com`). The system will detect the key name in your command text and export it as environment variable before it executes your command. * Secret detection: The system performs case-insensitive matching to find secret keys in your command text. If a registered secret key appears anywhere in your command, its value will be made available as an environment variable. * Security: Secret values are automatically masked in command output to prevent accidental exposure. You will see `` instead of the actual secret value in the output. +* Refreshing expired secrets: Some secrets (like GITHUB_TOKEN) may be updated periodically or expire over time. If a secret stops working (e.g., authentication failures), try using it again in a new command - the system will automatically use the refreshed value. For example, if GITHUB_TOKEN was used in a git remote URL and later expired, you can update the remote URL with the current token: `git remote set-url origin https://${GITHUB_TOKEN}@github.com/username/repo.git` to pick up the refreshed token value. ### Terminal Reset * Terminal reset: If the terminal becomes unresponsive, you can set the "reset" parameter to `true` to create a new terminal session. This will terminate the current session and start fresh.