diff --git a/letta/agents/letta_agent.py b/letta/agents/letta_agent.py index e8718651..77b36d99 100644 --- a/letta/agents/letta_agent.py +++ b/letta/agents/letta_agent.py @@ -1903,7 +1903,7 @@ class LettaAgent(BaseAgent): agent_step_span.add_event(name="tool_execution_started") # Decrypt environment variable values - sandbox_env_vars = {var.key: var.get_value_secret().get_plaintext() for var in agent_state.secrets} + sandbox_env_vars = {var.key: var.value_enc.get_plaintext() if var.value_enc else None for var in agent_state.secrets} tool_execution_manager = ToolExecutionManager( agent_state=agent_state, message_manager=self.message_manager, diff --git a/letta/agents/letta_agent_v2.py b/letta/agents/letta_agent_v2.py index c445c276..099d0ccb 100644 --- a/letta/agents/letta_agent_v2.py +++ b/letta/agents/letta_agent_v2.py @@ -1185,7 +1185,7 @@ class LettaAgentV2(BaseAgentV2): agent_step_span.add_event(name="tool_execution_started") # Decrypt environment variable values - sandbox_env_vars = {var.key: var.get_value_secret().get_plaintext() for var in agent_state.secrets} + sandbox_env_vars = {var.key: var.value_enc.get_plaintext() if var.value_enc else None for var in agent_state.secrets} tool_execution_manager = ToolExecutionManager( agent_state=agent_state, message_manager=self.message_manager, diff --git a/letta/agents/voice_agent.py b/letta/agents/voice_agent.py index a173ff47..a65905ad 100644 --- a/letta/agents/voice_agent.py +++ b/letta/agents/voice_agent.py @@ -439,7 +439,7 @@ class VoiceAgent(BaseAgent): # Use ToolExecutionManager for modern tool execution # Decrypt environment variable values - sandbox_env_vars = {var.key: var.get_value_secret().get_plaintext() for var in agent_state.secrets} + sandbox_env_vars = {var.key: var.value_enc.get_plaintext() if var.value_enc else None for var in agent_state.secrets} tool_execution_manager = ToolExecutionManager( agent_state=agent_state, message_manager=self.message_manager, diff --git a/letta/orm/sandbox_config.py b/letta/orm/sandbox_config.py index a2ff2799..a02b1391 100644 --- a/letta/orm/sandbox_config.py +++ b/letta/orm/sandbox_config.py @@ -46,7 +46,7 @@ class SandboxEnvironmentVariable(SqlalchemyBase, OrganizationMixin, SandboxConfi id: Mapped[str] = mapped_column(String, primary_key=True, nullable=False) key: Mapped[str] = mapped_column(String, nullable=False, doc="The name of the environment variable.") - value: Mapped[str] = mapped_column(String, nullable=False, doc="The value of the environment variable.") + value: Mapped[str] = mapped_column(String, nullable=False, doc="The value of the environment variable (deprecated, use value_enc).") description: Mapped[Optional[str]] = mapped_column(String, nullable=True, doc="An optional description of the environment variable.") # encrypted columns @@ -71,7 +71,7 @@ class AgentEnvironmentVariable(SqlalchemyBase, OrganizationMixin, AgentMixin): # TODO: We want to migrate all the ORM models to do this, so we will need to move this to the SqlalchemyBase id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: f"agent-env-{uuid.uuid4()}") key: Mapped[str] = mapped_column(String, nullable=False, doc="The name of the environment variable.") - value: Mapped[str] = mapped_column(String, nullable=False, doc="The value of the environment variable.") + value: Mapped[str] = mapped_column(String, nullable=False, doc="The value of the environment variable (deprecated, use value_enc).") description: Mapped[Optional[str]] = mapped_column(String, nullable=True, doc="An optional description of the environment variable.") # encrypted columns diff --git a/letta/schemas/agent.py b/letta/schemas/agent.py index bb96cd9d..ec5a2764 100644 --- a/letta/schemas/agent.py +++ b/letta/schemas/agent.py @@ -165,10 +165,10 @@ class AgentState(OrmMetadataBase, validate_assignment=True): ) def get_agent_env_vars_as_dict(self) -> Dict[str, str]: - # Get environment variables for this agent specifically + # Get environment variables for this agent specifically (read from encrypted value_enc) per_agent_env_vars = {} for agent_env_var_obj in self.secrets: - per_agent_env_vars[agent_env_var_obj.key] = agent_env_var_obj.value + per_agent_env_vars[agent_env_var_obj.key] = agent_env_var_obj.value_enc.get_plaintext() if agent_env_var_obj.value_enc else None return per_agent_env_vars @model_validator(mode="after") diff --git a/letta/schemas/environment_variables.py b/letta/schemas/environment_variables.py index f9198470..4d1d3afd 100644 --- a/letta/schemas/environment_variables.py +++ b/letta/schemas/environment_variables.py @@ -5,14 +5,13 @@ from pydantic import Field, model_validator from letta.schemas.enums import PrimitiveType from letta.schemas.letta_base import LettaBase, OrmMetadataBase from letta.schemas.secret import Secret -from letta.settings import settings # Base Environment Variable class EnvironmentVariableBase(OrmMetadataBase): id: str = Field(..., description="The unique identifier for the environment variable.") key: str = Field(..., description="The name of the environment variable.") - value: str = Field(..., description="The value of the environment variable.") + value: str = Field(..., description="The value of the environment variable.", repr=False) description: Optional[str] = Field(None, description="An optional description of the environment variable.") organization_id: Optional[str] = Field(None, description="The ID of the organization this environment variable belongs to.") @@ -20,36 +19,24 @@ class EnvironmentVariableBase(OrmMetadataBase): # Secret class handles validation and serialization automatically via __get_pydantic_core_schema__ value_enc: Secret | None = Field(None, description="Encrypted value as Secret object") - # TODO: deprecate value and use value_enc + # TODO: remove this in favor of value_enc, this is a bad pattern but need to support for now given our agent state dependency + # Note: DB writes are protected by managers which explicitly write value="" to DB. + # This validator syncs `value` and `value_enc` for backward compatibility: + # - If `value_enc` is set but `value` is empty -> populate `value` from decrypted `value_enc` + # - If `value` is set but `value_enc` is empty -> populate `value_enc` from encrypted `value` @model_validator(mode="after") - def populate_value_from_encrypted(self) -> "EnvironmentVariableBase": - """Populate value field from value_enc if value is empty but value_enc exists. - - This ensures API responses include the decrypted value in the `value` field - for backwards compatibility with clients that read from `value`. - """ - if (not self.value or self.value == "") and self.value_enc is not None: - self.value = self.value_enc.get_plaintext() or "" + def sync_value_and_value_enc(self): + """Sync deprecated `value` field with `value_enc` for backward compatibility.""" + if self.value_enc and not self.value: + # Decrypt value_enc -> value (for API responses) + plaintext = self.value_enc.get_plaintext() + if plaintext: + self.value = plaintext + elif self.value and not self.value_enc: + # Encrypt value -> value_enc (for backward compat when value is provided directly) + self.value_enc = Secret.from_plaintext(self.value) return self - def get_value_secret(self) -> Secret: - """Get the value as a Secret object. Prefers encrypted, falls back to plaintext with error logging.""" - # If value_enc is already a Secret, return it - if self.value_enc is not None: - return self.value_enc - # Fallback to plaintext with error logging via Secret.from_db() - return Secret.from_db(encrypted_value=None, plaintext_value=self.value) - - def set_value_secret(self, secret: Secret) -> None: - """Set value from a Secret object, directly storing the Secret.""" - self.value_enc = secret - # Also update plaintext field for dual-write during migration - secret_dict = secret.to_dict() - if not secret.was_encrypted: - self.value = secret_dict["plaintext"] - else: - self.value = None - class EnvironmentVariableCreateBase(LettaBase): key: str = Field(..., description="The name of the environment variable.") diff --git a/letta/server/rest_api/routers/v1/agents.py b/letta/server/rest_api/routers/v1/agents.py index 786bc604..11106dd3 100644 --- a/letta/server/rest_api/routers/v1/agents.py +++ b/letta/server/rest_api/routers/v1/agents.py @@ -641,7 +641,7 @@ async def run_tool_for_agent( sandbox_env_vars = {} if agent.tool_exec_environment_variables: for env_var in agent.tool_exec_environment_variables: - sandbox_env_vars[env_var.key] = env_var.get_value_secret().get_plaintext() + sandbox_env_vars[env_var.key] = env_var.value_enc.get_plaintext() if env_var.value_enc else None # Create tool execution manager and execute the tool from letta.services.tool_executor.tool_execution_manager import ToolExecutionManager diff --git a/letta/services/agent_manager.py b/letta/services/agent_manager.py index 41597141..b25ded2b 100644 --- a/letta/services/agent_manager.py +++ b/letta/services/agent_manager.py @@ -559,15 +559,15 @@ class AgentManager: # Encrypt environment variable values env_rows = [] for key, val in agent_secrets.items(): + # Encrypt value (Secret.from_plaintext handles missing encryption key internally) + value_secret = Secret.from_plaintext(val) row = { "agent_id": aid, "key": key, - "value": val, + "value": "", # Empty string for NOT NULL constraint (deprecated, use value_enc) + "value_enc": value_secret.get_encrypted(), "organization_id": actor.organization_id, } - # Encrypt value (Secret.from_plaintext handles missing encryption key internally) - value_secret = Secret.from_plaintext(val) - row["value_enc"] = value_secret.get_encrypted() env_rows.append(row) result = await session.execute(insert(AgentEnvironmentVariable).values(env_rows).returning(AgentEnvironmentVariable.id)) @@ -836,32 +836,29 @@ class AgentManager: # Only re-encrypt if the value has actually changed env_rows = [] for k, v in agent_secrets.items(): - row = { - "agent_id": aid, - "key": k, - "value": v, - "organization_id": agent.organization_id, - } - # Check if value changed to avoid unnecessary re-encryption existing_env = existing_env_vars.get(k) existing_value = None - if existing_env: - if existing_env.value_enc: - existing_secret = Secret.from_encrypted(existing_env.value_enc) - existing_value = existing_secret.get_plaintext() - elif existing_env.value: - existing_value = existing_env.value + if existing_env and existing_env.value_enc: + existing_secret = Secret.from_encrypted(existing_env.value_enc) + existing_value = existing_secret.get_plaintext() # Encrypt value (reuse existing encrypted value if unchanged) if existing_value == v and existing_env and existing_env.value_enc: # Value unchanged, reuse existing encrypted value - row["value_enc"] = existing_env.value_enc + value_enc = existing_env.value_enc else: # Value changed or new, encrypt value_secret = Secret.from_plaintext(v) - row["value_enc"] = value_secret.get_encrypted() + value_enc = value_secret.get_encrypted() + row = { + "agent_id": aid, + "key": k, + "value": "", # Empty string for NOT NULL constraint (deprecated, use value_enc) + "value_enc": value_enc, + "organization_id": agent.organization_id, + } env_rows.append(row) if env_rows: diff --git a/letta/services/sandbox_config_manager.py b/letta/services/sandbox_config_manager.py index 2f6191ac..9a4ac060 100644 --- a/letta/services/sandbox_config_manager.py +++ b/letta/services/sandbox_config_manager.py @@ -202,13 +202,14 @@ class SandboxConfigManager: return db_env_var else: async with db_registry.async_session() as session: - # Explicitly encrypt the value before storing + # Encrypt the value before storing (only to value_enc, not plaintext) from letta.schemas.secret import Secret - if env_var.value is not None: + if env_var.value: env_var.value_enc = Secret.from_plaintext(env_var.value) + env_var.value = "" # Don't store plaintext, use empty string for NOT NULL constraint - env_var = SandboxEnvVarModel(**env_var.model_dump(to_orm=True, exclude_none=True)) + env_var = SandboxEnvVarModel(**env_var.model_dump(to_orm=True)) await env_var.create_async(session, actor=actor) return env_var.to_pydantic() @@ -227,19 +228,16 @@ class SandboxConfigManager: if "value" in update_data and update_data["value"] is not None: from letta.schemas.secret import Secret - # Check if value changed + # Check if value changed by comparing with existing encrypted value existing_value = None if env_var.value_enc: existing_secret = Secret.from_encrypted(env_var.value_enc) existing_value = existing_secret.get_plaintext() - elif env_var.value: - existing_value = env_var.value # Only re-encrypt if different if existing_value != update_data["value"]: env_var.value_enc = Secret.from_plaintext(update_data["value"]).get_encrypted() - # Keep plaintext for dual-write during migration - env_var.value = update_data["value"] + # Don't store plaintext anymore # Remove from update_data since we set directly on env_var update_data.pop("value", None) @@ -315,8 +313,7 @@ class SandboxConfigManager: result = {} for env_var in env_vars: # Decrypt the value before returning - value_secret = env_var.get_value_secret() - result[env_var.key] = value_secret.get_plaintext() + result[env_var.key] = env_var.value_enc.get_plaintext() if env_var.value_enc else None return result @enforce_types @@ -327,7 +324,7 @@ class SandboxConfigManager: ) -> Dict[str, str]: env_vars = await self.list_sandbox_env_vars_async(sandbox_config_id, actor, after, limit) # Decrypt values before returning - return {env_var.key: env_var.get_value_secret().get_plaintext() for env_var in env_vars} + return {env_var.key: env_var.value_enc.get_plaintext() if env_var.value_enc else None for env_var in env_vars} @enforce_types @trace_method diff --git a/letta/services/tool_sandbox/modal_sandbox.py b/letta/services/tool_sandbox/modal_sandbox.py index 0bd4a6ec..85af4e93 100644 --- a/letta/services/tool_sandbox/modal_sandbox.py +++ b/letta/services/tool_sandbox/modal_sandbox.py @@ -145,7 +145,7 @@ class AsyncToolSandboxModal(AsyncToolSandboxBase): # Add agent-specific environment variables (these override sandbox-level) if agent_state and agent_state.secrets: for secret in agent_state.secrets: - env_vars[secret.key] = secret.get_value_secret().get_plaintext() + env_vars[secret.key] = secret.value_enc.get_plaintext() if secret.value_enc else None # Add any additional env vars passed at runtime (highest priority) if additional_env_vars: diff --git a/tests/managers/test_agent_manager.py b/tests/managers/test_agent_manager.py index 1bcb3c7e..0804bafe 100644 --- a/tests/managers/test_agent_manager.py +++ b/tests/managers/test_agent_manager.py @@ -1290,10 +1290,9 @@ async def test_agent_environment_variables_decrypt_on_read(server: SyncServer, d decrypted = secret_obj.value_enc.get_plaintext() assert decrypted == "test-value-67890" - # Verify get_value_secret() method works - value_secret = secret_obj.get_value_secret() - assert isinstance(value_secret, Secret) - assert value_secret.get_plaintext() == "test-value-67890" + # Verify direct value_enc access works + assert isinstance(secret_obj.value_enc, Secret) + assert secret_obj.value_enc.get_plaintext() == "test-value-67890" @pytest.mark.asyncio