Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
3 changes: 3 additions & 0 deletions openhands-agent-server/openhands/agent_server/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ def _default_session_api_keys():

def _default_secret_key() -> SecretStr | None:
session_api_key = os.getenv(SESSION_API_KEY_ENV)
if session_api_key:
return SecretStr(session_api_key)
session_api_key = os.getenv("OH_SESSION_API_KEYS_0")
if session_api_key:
return SecretStr(session_api_key)
return None
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
session_api_key = os.getenv(SESSION_API_KEY_ENV)
if session_api_key:
return SecretStr(session_api_key)
session_api_key = os.getenv("OH_SESSION_API_KEYS_0")
if session_api_key:
return SecretStr(session_api_key)
return None
session_api_keys = _default_session_api_keys()
if len(session_api_keys) > 0:
return SecretStr(session_api_keys[0])
return None

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,7 @@ async def start(self):
stuck_detection=self.stored.stuck_detection,
visualizer=None,
secrets=self.stored.secrets,
cipher=self.cipher,
)

# Set confirmation mode if enabled
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
from openhands.sdk.security.confirmation_policy import (
ConfirmationPolicyBase,
)
from openhands.sdk.utils.cipher import Cipher
from openhands.sdk.workspace import LocalWorkspace


Expand Down Expand Up @@ -77,6 +78,7 @@ def __init__(
type[ConversationVisualizerBase] | ConversationVisualizerBase | None
) = DefaultConversationVisualizer,
secrets: Mapping[str, SecretValue] | None = None,
cipher: Cipher | None = None,
**_: object,
):
"""Initialize the conversation.
Expand Down Expand Up @@ -105,6 +107,10 @@ def __init__(
a dict with keys: 'action_observation', 'action_error',
'monologue', 'alternating_pattern'. Values are integers
representing the number of repetitions before triggering.
cipher: Optional cipher for encrypting/decrypting secrets in persisted
state. If provided, secrets are encrypted when saving and
decrypted when loading. If not provided, secrets are redacted
(lost) on serialization.
"""
super().__init__() # Initialize with span tracking
# Mark cleanup as initiated as early as possible to avoid races or partially
Expand Down Expand Up @@ -134,6 +140,7 @@ def __init__(
else None,
max_iterations=max_iteration_per_run,
stuck_detection=stuck_detection,
cipher=cipher,
)

# Default callback: persist every event to state
Expand Down
27 changes: 25 additions & 2 deletions openhands-sdk/openhands/sdk/conversation/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
ConfirmationPolicyBase,
NeverConfirm,
)
from openhands.sdk.utils.cipher import Cipher
from openhands.sdk.utils.models import OpenHandsModel
from openhands.sdk.workspace.base import BaseWorkspace

Expand Down Expand Up @@ -124,6 +125,7 @@ class ConversationState(OpenHandsModel):
# ===== Private attrs (NOT Fields) =====
_fs: FileStore = PrivateAttr() # filestore for persistence
_events: EventLog = PrivateAttr() # now the storage for events
_cipher: Cipher | None = PrivateAttr(default=None) # cipher for secret encryption
_autosave_enabled: bool = PrivateAttr(
default=False
) # to avoid recursion during init
Expand Down Expand Up @@ -166,8 +168,20 @@ def set_on_state_change(self, callback: ConversationCallbackType | None) -> None
def _save_base_state(self, fs: FileStore) -> None:
"""
Persist base state snapshot (no events; events are file-backed).

If a cipher is configured, secrets will be encrypted. Otherwise, they
will be redacted (serialized as '**********').
"""
payload = self.model_dump_json(exclude_none=True)
context = {"cipher": self._cipher} if self._cipher else None
# Warn if secrets exist but no cipher is configured
if not self._cipher and self.secret_registry.secret_sources:
logger.warning(
f"Saving conversation state without cipher - "
f"{len(self.secret_registry.secret_sources)} secret(s) will be "
"redacted and lost on restore. Consider providing a cipher to "
"preserve secrets."
)
payload = self.model_dump_json(exclude_none=True, context=context)
fs.write(BASE_STATE, payload)

# ===== Factory: open-or-create (no load/save methods needed) =====
Expand All @@ -180,6 +194,7 @@ def create(
persistence_dir: str | None = None,
max_iterations: int = 500,
stuck_detection: bool = True,
cipher: Cipher | None = None,
) -> "ConversationState":
"""Create a new conversation state or resume from persistence.

Expand All @@ -203,6 +218,10 @@ def create(
persistence_dir: Directory for persisting state and events
max_iterations: Maximum iterations per run
stuck_detection: Whether to enable stuck detection
cipher: Optional cipher for encrypting/decrypting secrets in
persisted state. If provided, secrets are encrypted when
saving and decrypted when loading. If not provided, secrets
are redacted (lost) on serialization.

Returns:
ConversationState ready for use
Expand All @@ -224,7 +243,9 @@ def create(

# ---- Resume path ----
if base_text:
state = cls.model_validate(json.loads(base_text))
# Use cipher context for decrypting secrets if provided
context = {"cipher": cipher} if cipher else None
state = cls.model_validate(json.loads(base_text), context=context)

# Restore the conversation with the same id
if state.id != id:
Expand All @@ -236,6 +257,7 @@ def create(
# Attach event log early so we can read history for tool verification
state._fs = file_store
state._events = EventLog(file_store, dir_path=EVENTS_DIR)
state._cipher = cipher

# Verify compatibility (agent class + tools)
agent.verify(state.agent, events=state._events)
Expand Down Expand Up @@ -272,6 +294,7 @@ def create(
)
state._fs = file_store
state._events = EventLog(file_store, dir_path=EVENTS_DIR)
state._cipher = cipher
state.stats = ConversationStats()

state._save_base_state(file_store) # initial snapshot
Expand Down
23 changes: 19 additions & 4 deletions openhands-sdk/openhands/sdk/secret/secrets.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,14 @@
import httpx
from pydantic import Field, SecretStr, field_serializer, field_validator

from openhands.sdk.logger import get_logger
from openhands.sdk.utils.models import DiscriminatedUnionMixin
from openhands.sdk.utils.pydantic_secrets import serialize_secret, validate_secret


logger = get_logger(__name__)


class SecretSource(DiscriminatedUnionMixin, ABC):
"""Source for a named secret which may be obtained dynamically"""

Expand All @@ -25,9 +29,11 @@ def get_value(self) -> str | None:
class StaticSecret(SecretSource):
"""A secret stored locally"""

value: SecretStr
value: SecretStr | None = None

def get_value(self):
def get_value(self) -> str | None:
if self.value is None:
return None
return self.value.get_secret_value()

@field_validator("value")
Expand Down Expand Up @@ -58,7 +64,12 @@ def _validate_secrets(cls, headers: dict[str, str], info):
for key, value in headers.items():
if _is_secret_header(key):
secret_value = validate_secret(SecretStr(value), info)
assert secret_value is not None
# Skip headers with redacted/empty secret values
if secret_value is None:
logger.debug(
f"Skipping redacted header '{key}' during deserialization"
)
continue
result[key] = secret_value.get_secret_value()
else:
result[key] = value
Expand All @@ -70,7 +81,11 @@ def _serialize_secrets(self, headers: dict[str, str], info):
for key, value in headers.items():
if _is_secret_header(key):
secret_value = serialize_secret(SecretStr(value), info)
assert secret_value is not None
if secret_value is None:
logger.debug(
f"Skipping redacted header '{key}' during serialization"
)
continue
result[key] = secret_value
else:
result[key] = value
Expand Down
Loading
Loading