Skip to content
Closed
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
8 changes: 7 additions & 1 deletion openhands-sdk/openhands/sdk/conversation/conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from openhands.sdk.plugin import PluginSource
from openhands.sdk.secret import SecretValue
from openhands.sdk.tool.client_tool import ClientToolSpec
from openhands.sdk.utils.redact import redact_url_credentials
from openhands.sdk.workspace import LocalWorkspace, RemoteWorkspace


Expand Down Expand Up @@ -171,7 +172,12 @@ def __new__(

# 2. Auto-generate plugins/skills tag from plugins parameter
if plugins:
plugin_urls = [p.source_url for p in plugins if p.source_url]
# tags are a plain dict persisted verbatim — redact creds here.
plugin_urls = [
redact_url_credentials(url)
for p in plugins
if (url := p.source_url)
Comment thread
haya-asif marked this conversation as resolved.
]
if plugin_urls:
effective_tags["plugins"] = ",".join(plugin_urls)
logger.debug(f"Added plugins tag with {len(plugin_urls)} plugin(s)")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -785,8 +785,15 @@ def __init__(
"tool_module_qualnames": tool_qualnames,
# Include agent definitions for subagent registration on server
"agent_definitions": serialized_defs,
# Include plugins to load on server
"plugins": [p.model_dump() for p in plugins] if plugins else None,
# expose_secrets: send the real source so the server can clone.
"plugins": (
[
p.model_dump(mode="json", context={"expose_secrets": True})
for p in plugins
]
if plugins
else None
),
# Include hook_config for server-side hooks
"hook_config": hook_config.model_dump() if hook_config else None,
# Include client-defined tool specs (no server-side executor)
Expand Down
25 changes: 24 additions & 1 deletion openhands-sdk/openhands/sdk/extensions/installation/info.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,22 @@

from datetime import UTC, datetime
from pathlib import Path
from typing import Any

from pydantic import BaseModel, Field
from pydantic import (
BaseModel,
Field,
FieldSerializationInfo,
ValidationInfo,
field_serializer,
field_validator,
)

from openhands.sdk.extensions.installation.interface import ExtensionProtocol
from openhands.sdk.utils.pydantic_secrets import (
serialize_credential_url,
validate_credential_url,
)


class InstallationInfo(BaseModel):
Expand Down Expand Up @@ -36,6 +48,17 @@ class InstallationInfo(BaseModel):
)
install_path: Path = Field(description="Path where the extension is installed")

@field_validator("source", mode="before")
@classmethod
def _decrypt_source(cls, v: Any, info: ValidationInfo) -> Any:
"""Decrypt at-rest ciphertext (see validate_credential_url)."""
return validate_credential_url(v, info)

@field_serializer("source", when_used="always")
def _serialize_source(self, source: str, info: FieldSerializationInfo) -> str:
"""Redact/expose/encrypt by context (see serialize_credential_url)."""
return serialize_credential_url(source, info)

@staticmethod
def from_extension(
extension: ExtensionProtocol,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,9 @@ def save_to_dir(self, installed_dir: Path) -> None:
"""Save metadata to the installed extensions directory."""
metadata_path = self.get_metadata_path(installed_dir)
metadata_path.parent.mkdir(parents=True, exist_ok=True)
metadata_path.write_text(self.model_dump_json(indent=2))
metadata_path.write_text(
self.model_dump_json(indent=2, context={"expose_secrets": "plaintext"})
)

def validate_tracked(self, installed_dir: Path) -> list[InstallationInfo]:
"""Validate tracked extensions exist on disk.
Expand Down
24 changes: 23 additions & 1 deletion openhands-sdk/openhands/sdk/plugin/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,20 @@
from typing import TYPE_CHECKING, Any

import frontmatter
from pydantic import BaseModel, Field, field_validator
from pydantic import (
BaseModel,
Field,
FieldSerializationInfo,
ValidationInfo,
field_serializer,
field_validator,
)

from openhands.sdk.utils.path import to_posix_path
from openhands.sdk.utils.pydantic_secrets import (
serialize_credential_url,
validate_credential_url,
)
from openhands.sdk.utils.redact import redact_url_credentials


Expand Down Expand Up @@ -64,6 +75,17 @@ def validate_repo_path(cls, v: str | None) -> str | None:
)
return v

@field_validator("source", mode="before")
@classmethod
def _decrypt_source(cls, v: Any, info: ValidationInfo) -> Any:
"""Decrypt at-rest ciphertext (see validate_credential_url)."""
return validate_credential_url(v, info)

@field_serializer("source", when_used="always")
def _serialize_source(self, source: str, info: FieldSerializationInfo) -> str:
"""Redact/expose/encrypt by context (see serialize_credential_url)."""
return serialize_credential_url(source, info)

@property
def source_url(self) -> str | None:
"""Convert the plugin source to a canonical URL.
Expand Down
29 changes: 29 additions & 0 deletions openhands-sdk/openhands/sdk/utils/pydantic_secrets.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from pydantic import SecretStr, ValidationInfo

from openhands.sdk.utils.cipher import FERNET_TOKEN_PREFIX, Cipher
from openhands.sdk.utils.redact import redact_url_credentials


REDACTED_SECRET_VALUE = "**********"
Expand Down Expand Up @@ -195,3 +196,31 @@ def _decrypt_env(cls, value, info):
k: decrypt_str_with_cipher_or_keep(cipher, v, description=description)
for k, v in value.items()
}


def serialize_credential_url(source: str, info) -> str:
"""Serialize a credential-bearing URL by context: the real value under
``expose_secrets``, an encrypted token under a ``cipher``, else only the
userinfo redacted (``https://****@host``)."""
mode = resolve_expose_mode(info.context)
if mode == "plaintext":
return source
if mode == "encrypted":
cipher: Cipher | None = info.context.get("cipher") if info.context else None
if cipher is None:
raise MissingCipherError(
"Cannot encrypt credentialed source: no cipher configured. "
"Set OH_SECRET_KEY environment variable."
)
token = cipher.encrypt(SecretStr(source))
assert token is not None # encrypt() returns None only for a None input
return token
return redact_url_credentials(source)


def validate_credential_url(value: Any, info):
"""Decrypt an at-rest cipher token back to the URL; no-op without a cipher."""
cipher: Cipher | None = info.context.get("cipher") if info.context else None
if cipher is None:
return value
return decrypt_str_with_cipher_or_keep(cipher, value, description="plugin source")
27 changes: 27 additions & 0 deletions tests/sdk/conversation/remote/test_remote_conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,33 @@ def test_remote_conversation_sends_observability_fields(self, mock_ws_client):
assert payload["observability_tags"] == ["sdk", "remote"]
assert payload["user_id"] == "test-user-42"

@patch(
"openhands.sdk.conversation.impl.remote_conversation.WebSocketCallbackClient"
)
def test_remote_conversation_sends_real_plugin_source(self, mock_ws_client):
"""The create payload carries the real plugin source (credential and
${VAR} placeholder intact) so the server can clone a private plugin."""
from openhands.sdk.plugin import PluginSource

conversation_id = str(uuid.uuid4())
mock_client_instance = self.setup_mock_client(conversation_id=conversation_id)
mock_ws_client.return_value = Mock()

source = "https://x-token-auth:${MY_TOKEN}@host/org/repo.git"
RemoteConversation(
agent=self.agent,
workspace=self.workspace,
plugins=[PluginSource(source=source)],
)

create_call = next(
call
for call in mock_client_instance.request.call_args_list
if call[0][0] == "POST" and call[0][1] == "/api/conversations"
)
payload = create_call.kwargs["json"]
assert payload["plugins"][0]["source"] == source

@patch(
"openhands.sdk.conversation.impl.remote_conversation.WebSocketCallbackClient"
)
Expand Down
31 changes: 31 additions & 0 deletions tests/sdk/conversation/test_local_conversation_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -795,6 +795,37 @@ def test_plugin_hooks_combined_with_explicit_hooks(
# (The actual hook_processor is internal, but we trust the merging works)
conversation.close()

def test_plugin_load_log_never_leaks_credentials(
self, tmp_path: Path, basic_agent, caplog: pytest.LogCaptureFixture
):
"""Plugin-load logs must never contain the source credential (the
serializer covers model dumps, not f-string log lines)."""
plugin_dir = create_test_plugin(
tmp_path / "plugin",
name="test-plugin",
skills=[{"name": "s", "content": "c"}],
)
workspace = tmp_path / "workspace"
workspace.mkdir()
conversation = LocalConversation(
agent=basic_agent,
workspace=workspace,
plugins=[
PluginSource(source="https://oauth2:LEAKME@host.example.com/o/r.git")
],
visualizer=None,
)
with patch(
"openhands.sdk.conversation.impl.local_conversation."
"fetch_plugin_with_resolution",
return_value=(plugin_dir, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"),
):
with caplog.at_level(logging.DEBUG):
conversation._ensure_plugins_loaded()

assert "LEAKME" not in caplog.text
conversation.close()

def test_hook_sub_conversations_receive_persistence_base_dir(
self, tmp_path: Path, basic_agent
):
Expand Down
25 changes: 25 additions & 0 deletions tests/sdk/extensions/installation/test_installation_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from pathlib import Path

from openhands.sdk.extensions.installation import InstallationInfo
from openhands.sdk.utils.cipher import Cipher


@dataclass
Expand Down Expand Up @@ -34,3 +35,27 @@ def test_installation_info_from_extension():
assert info.repo_path is None

assert datetime.fromisoformat(info.installed_at)


def test_source_redacted_by_default_exposed_under_context():
"""Default dumps mask the credential; expose_secrets keeps the real source."""
cred = "https://oauth2:SUPER_SECRET@github.com/org/repo.git"
info = InstallationInfo(name="x", source=cred, install_path=Path("/tmp/x"))
assert info.source == cred # raw in memory
assert "SUPER_SECRET" not in info.model_dump_json() # default redacts
assert info.model_dump(context={"expose_secrets": "plaintext"})["source"] == cred


def test_source_encrypts_at_rest_under_cipher():
"""A cipher context encrypts the source and decrypts it back on load."""
cipher = Cipher("k")
cred = "https://oauth2:SUPER_SECRET@github.com/org/repo.git"
info = InstallationInfo(name="x", source=cred, install_path=Path("/tmp/x"))
token = info.model_dump(context={"cipher": cipher})["source"]
assert token.startswith("gAAAAA")
assert "SUPER_SECRET" not in token
back = InstallationInfo.model_validate(
{"name": "x", "source": token, "install_path": "/tmp/x"},
context={"cipher": cipher},
)
assert back.source == cred
21 changes: 21 additions & 0 deletions tests/sdk/extensions/installation/test_installation_manager.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import shutil
from pathlib import Path
from unittest.mock import patch

import pytest
from pydantic import BaseModel
Expand Down Expand Up @@ -103,6 +104,26 @@ def test_install_from_local_path(
assert mock_extension.name in metadata.extensions


def test_update_reclones_with_credentialed_source(
manager: InstallationManager[MockExtension],
mock_extension_dir: Path,
mock_extension: MockExtension,
):
"""update() re-clones using the real credential stored in .installed.json
(a redacted source there would make a private-extension update fail to clone)."""
cred = "https://oauth2:SUPER_SECRET@github.com/org/repo.git"
with patch(
"openhands.sdk.extensions.installation.manager.fetch_with_resolution",
return_value=(mock_extension_dir, "abc123"),
) as mock_fetch:
manager.install(source=cred, force=True) # records cred in .installed.json
mock_fetch.reset_mock()
manager.update(mock_extension.name) # re-fetch from the stored source

assert mock_fetch.call_count == 1
assert mock_fetch.call_args.kwargs["source"] == cred


def test_install_already_exist_raises_error(
manager: InstallationManager[MockExtension],
mock_extension_dir: Path,
Expand Down
17 changes: 17 additions & 0 deletions tests/sdk/extensions/installation/test_installation_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,23 @@ def test_load_from_dir_and_save_to_dir(tmp_path: Path):
assert metadata == loaded_metadata


def test_installed_json_keeps_source_for_update(tmp_path: Path):
""".installed.json keeps the real source so ExtensionManager.update() can
re-clone (this layer has no cipher); a default dump still redacts."""
installation_dir = tmp_path / "installed"
installation_dir.mkdir()
cred = "https://oauth2:SUPER_SECRET@github.com/org/repo.git"
info = InstallationInfo(
name="ext", source=cred, install_path=installation_dir / "ext"
)
InstallationMetadata(extensions={"ext": info}).save_to_dir(installation_dir)

reloaded = InstallationMetadata.load_from_dir(installation_dir)
assert reloaded.extensions["ext"].source == cred # update() re-clones from this

assert "SUPER_SECRET" not in info.model_dump_json() # default dump redacts


def test_load_from_dir_invalid_json(tmp_path: Path):
"""Test loading invalid JSON returns empty metadata."""
installation_dir = tmp_path / "installed"
Expand Down
Loading
Loading