Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions openhands-tools/openhands/tools/execute_bash/definition.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,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:
Expand Down Expand Up @@ -213,6 +216,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 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 `<secret-hidden>` 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.
Expand Down
23 changes: 15 additions & 8 deletions openhands-tools/openhands/tools/execute_bash/impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions openhands-tools/openhands/tools/execute_bash/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
66 changes: 66 additions & 0 deletions tests/tools/execute_bash/test_secrets_masking.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading