diff --git a/openhands-tools/openhands/tools/terminal/definition.py b/openhands-tools/openhands/tools/terminal/definition.py index 3ccd0f9203..9004a4c0f1 100644 --- a/openhands-tools/openhands/tools/terminal/definition.py +++ b/openhands-tools/openhands/tools/terminal/definition.py @@ -117,6 +117,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}]" llm_content.append(TextContent(text=maybe_truncate(ret, MAX_CMD_OUTPUT_SIZE))) @@ -213,6 +216,13 @@ 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: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. * 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. diff --git a/openhands-tools/openhands/tools/terminal/impl.py b/openhands-tools/openhands/tools/terminal/impl.py index f24dab6dfb..32b80160fa 100644 --- a/openhands-tools/openhands/tools/terminal/impl.py +++ b/openhands-tools/openhands/tools/terminal/impl.py @@ -163,16 +163,26 @@ def __call__( self._export_envs(action, conversation) observation = self.session.execute(action) - # Apply automatic secrets masking - content_text = observation.text - - if content_text 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_content = secret_registry.mask_secrets_in_output(content_text) - if masked_content: - data = observation.model_dump(exclude={"content"}) - return ExecuteBashObservation.from_text(text=masked_content, **data) + + # 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 + content_text = observation.text + if content_text: + masked_content = secret_registry.mask_secrets_in_output( + content_text + ) + if masked_content: + data = observation.model_dump(exclude={"content"}) + return ExecuteBashObservation.from_text( + text=masked_content, **data + ) except Exception: pass diff --git a/openhands-tools/openhands/tools/terminal/metadata.py b/openhands-tools/openhands/tools/terminal/metadata.py index e7c6b9a468..6cd6d28003 100644 --- a/openhands-tools/openhands/tools/terminal/metadata.py +++ b/openhands-tools/openhands/tools/terminal/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/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"} diff --git a/tests/tools/terminal/test_secrets_masking.py b/tests/tools/terminal/test_secrets_masking.py index 57948d1478..dea847e8fc 100644 --- a/tests/tools/terminal/test_secrets_masking.py +++ b/tests/tools/terminal/test_secrets_masking.py @@ -89,3 +89,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.from_text( + text="Test output", + command="echo 'Test command'", + exit_code=0, + ) + 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()