Skip to content
Open
10 changes: 10 additions & 0 deletions openhands-tools/openhands/tools/terminal/definition.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand Down Expand Up @@ -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 `<secret-hidden>` 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.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
* 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.
* 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 should 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.
* If it still fails, report it to the user.

OK! Looks good. Verbose, but these LLMs will deal with it.

This is not always true, though: this happens on the Cloud, and I don't think, as of now, anywhere else in the project. So how about we add an escape hatch, maybe, for the LLM to not assume it will always happen?

Copy link
Collaborator

@enyst enyst Nov 11, 2025

Choose a reason for hiding this comment

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

e.g.
"* If it still fails, report it to the user."
or "you can ask the user about it"


### 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
26 changes: 18 additions & 8 deletions openhands-tools/openhands/tools/terminal/impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions openhands-tools/openhands/tools/terminal/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
42 changes: 42 additions & 0 deletions tests/sdk/conversation/test_secrets_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
66 changes: 66 additions & 0 deletions tests/tools/terminal/test_secrets_masking.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading