Skip to content
Merged
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
28 changes: 23 additions & 5 deletions openhands-agent-server/openhands/agent_server/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,37 @@


# Environment variable constants
SESSION_API_KEY_ENV = "SESSION_API_KEY"
V0_SESSION_API_KEY_ENV = "SESSION_API_KEY"
V1_SESSION_API_KEY_ENV = "OH_SESSION_API_KEYS_0"
ENVIRONMENT_VARIABLE_PREFIX = "OH"
_logger = logging.getLogger(__name__)


def _default_session_api_keys():
# Legacy fallback for compability with old runtime API
"""
This function exists as a fallback to using this old V0 environment
variable. If new V1_SESSION_API_KEYS_0 environment variable exists,
it is read automatically by the EnvParser and this function is never
called.
"""
result = []
session_api_key = os.getenv(SESSION_API_KEY_ENV)
session_api_key = os.getenv(V0_SESSION_API_KEY_ENV)
if session_api_key:
result.append(session_api_key)
return result


def _default_secret_key() -> SecretStr | None:
session_api_key = os.getenv(SESSION_API_KEY_ENV)
"""
If the OH_SECRET_KEY environment variable is present, it is read by the EnvParser
and this function is never called. Otherwise, we fall back to using the first
available session_api_key - which we read from the environment.
We check both the V0 and V1 variables for this.
"""
session_api_key = os.getenv(V0_SESSION_API_KEY_ENV)
if session_api_key:
return SecretStr(session_api_key)
session_api_key = os.getenv(V1_SESSION_API_KEY_ENV)
if session_api_key:
return SecretStr(session_api_key)
return None
Expand Down Expand Up @@ -77,7 +92,10 @@ class Config(BaseModel):
description=(
"List of valid session API keys used to authenticate incoming requests. "
"Empty list implies the server will be unsecured. Any key in this list "
"will be accepted for authentication."
"will be accepted for authentication. Multiple keys are supported to "
"enable key rotation without service disruption - new keys can be added "
"to the list, then clients are updated with the new key, and finally the "
"old key is removed from the list. "
),
)
allow_cors_origins: list[str] = Field(
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