diff --git a/.env.example b/.env.example index 3706c242..5265e117 100644 --- a/.env.example +++ b/.env.example @@ -72,8 +72,12 @@ DATABASE_STRING="postgresql+psycopg://skyvern@localhost/skyvern" PORT=8000 # Analytics configuration: -# Distinct analytics ID (a UUID is generated if left blank). +# ANALYTICS_ID: Distinct analytics ID (a UUID is generated if left blank). ANALYTICS_ID="anonymous" +# 1Password Integration +# OP_SERVICE_ACCOUNT_TOKEN: API token for 1Password integration +OP_SERVICE_ACCOUNT_TOKEN="" + # Enable recording skyvern logs as artifacts -ENABLE_LOG_ARTIFACTS=false +ENABLE_LOG_ARTIFACTS=false \ No newline at end of file diff --git a/alembic/versions/2025_06_12_0806-1517a4ba63fa_db_script_for_1password_integration.py b/alembic/versions/2025_06_12_0806-1517a4ba63fa_db_script_for_1password_integration.py new file mode 100644 index 00000000..905f0742 --- /dev/null +++ b/alembic/versions/2025_06_12_0806-1517a4ba63fa_db_script_for_1password_integration.py @@ -0,0 +1,62 @@ +"""db script for 1password integration + +Revision ID: 1517a4ba63fa +Revises: add_run_timestamps +Create Date: 2025-06-12 08:06:13.439802+00:00 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "1517a4ba63fa" +down_revision: Union[str, None] = "add_run_timestamps" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "onepassword_credential_parameters", + sa.Column("onepassword_credential_parameter_id", sa.String(), nullable=False), + sa.Column("workflow_id", sa.String(), nullable=False), + sa.Column("key", sa.String(), nullable=False), + sa.Column("description", sa.String(), nullable=True), + sa.Column("vault_id", sa.String(), nullable=False), + sa.Column("item_id", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("modified_at", sa.DateTime(), nullable=False), + sa.Column("deleted_at", sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint("onepassword_credential_parameter_id"), + ) + op.create_index( + op.f("ix_onepassword_credential_parameters_onepassword_credential_parameter_id"), + "onepassword_credential_parameters", + ["onepassword_credential_parameter_id"], + unique=False, + ) + op.create_index( + op.f("ix_onepassword_credential_parameters_workflow_id"), + "onepassword_credential_parameters", + ["workflow_id"], + unique=False, + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index( + op.f("ix_onepassword_credential_parameters_workflow_id"), table_name="onepassword_credential_parameters" + ) + op.drop_index( + op.f("ix_onepassword_credential_parameters_onepassword_credential_parameter_id"), + table_name="onepassword_credential_parameters", + ) + op.drop_table("onepassword_credential_parameters") + # ### end Alembic commands ### diff --git a/docker-compose.yml b/docker-compose.yml index 88d105de..39a6462a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -118,6 +118,10 @@ services: # - BITWARDEN_CLIENT_ID=FILL_ME_IN_PLEASE # - BITWARDEN_CLIENT_SECRET=FILL_ME_IN_PLEASE # - BITWARDEN_MASTER_PASSWORD=FILL_ME_IN_PLEASE + + # 1Password Integration + # If you are looking to integrate Skyvern with 1Password, you can use the following environment variables. + # OP_SERVICE_ACCOUNT_TOKEN="" depends_on: postgres: condition: service_healthy diff --git a/poetry.lock b/poetry.lock index 10a67a15..962b60b2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. [[package]] name = "about-time" @@ -4496,6 +4496,45 @@ files = [ {file = "numpy-2.2.5.tar.gz", hash = "sha256:a9c0d994680cd991b1cb772e8b297340085466a6fe964bc9d4e80f5e2f43c291"}, ] +[[package]] +name = "onepassword-sdk" +version = "0.3.0" +description = "The 1Password Python SDK offers programmatic read access to your secrets in 1Password in an interface native to Python." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "onepassword_sdk-0.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cde57e9c2b07b420a8d4844f2260d0353defd8e008a690700a9c8ae1a8d53881"}, + {file = "onepassword_sdk-0.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:75cf002de86a0401838b635622d1a9f34c99f8c3eda86c7f35bb4ccfde534290"}, + {file = "onepassword_sdk-0.3.0-cp310-cp310-manylinux_2_32_aarch64.whl", hash = "sha256:607d6147294037f8a790120850d54880892f69c22b215a8240b83ee92776676f"}, + {file = "onepassword_sdk-0.3.0-cp310-cp310-manylinux_2_32_x86_64.whl", hash = "sha256:6d907d584cf8e8db04d4047566cc7378faa99264722d18b982636f3db5ffd54a"}, + {file = "onepassword_sdk-0.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:deceb924e1dd9f9c05046a6e48d7bb99653d07284dc2b50932da06ece9f412ff"}, + {file = "onepassword_sdk-0.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6510af9498efc76f1df18eda9cdc78a65a3143350dc59679fe3cf8bf8ad8982f"}, + {file = "onepassword_sdk-0.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:07482e5bd416eb0eaa87faf15307fe1529db46247a532ace45f373d3d220b7c2"}, + {file = "onepassword_sdk-0.3.0-cp311-cp311-manylinux_2_32_aarch64.whl", hash = "sha256:cac7e9229e47e0f03e9c30ca65616daf8818acd3aec4de8933a4a819bdbd9e0d"}, + {file = "onepassword_sdk-0.3.0-cp311-cp311-manylinux_2_32_x86_64.whl", hash = "sha256:d2f626f7b9bd8101d292e1faa017f94f48fe46d78895db50a5f8e77ca5e66633"}, + {file = "onepassword_sdk-0.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:2dec1f199e6db5b671411f2f0f7eed0b308ec922ce0985e061ea8433a5203d94"}, + {file = "onepassword_sdk-0.3.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f5a023212483f0936f9a2fff687221c8b2bdeb467fadba89b0f198de51956f21"}, + {file = "onepassword_sdk-0.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6ef97d00310e277e974be18f57b8a76661789a20afe5392138172a9ae827c3a1"}, + {file = "onepassword_sdk-0.3.0-cp312-cp312-manylinux_2_32_aarch64.whl", hash = "sha256:02bb56039392c78990f868e7f116be17f119d7c81004cbd7808669989a02f3f6"}, + {file = "onepassword_sdk-0.3.0-cp312-cp312-manylinux_2_32_x86_64.whl", hash = "sha256:21e748c7d8bdc8216dc19f0fb35dcee3148ddbcbcef45e86bcefc50cecc98ca0"}, + {file = "onepassword_sdk-0.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:f9c212e4ddebdd18845bf0974950cf5072f3174083b6b498756fc8713e82cd1a"}, + {file = "onepassword_sdk-0.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b1105e3f5c33a3086d038df910cb65a230d482b98fcb3f71cef155d1832c8f19"}, + {file = "onepassword_sdk-0.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:18b3a1f49eabf30e7b6de4389e3181d41802bb37ce2182c9162a0591c3705d77"}, + {file = "onepassword_sdk-0.3.0-cp313-cp313-manylinux_2_32_aarch64.whl", hash = "sha256:85c3a66fbe086d22ee6126dde3505859100ceb58129e2aca8a7d4b38aca7e03d"}, + {file = "onepassword_sdk-0.3.0-cp313-cp313-manylinux_2_32_x86_64.whl", hash = "sha256:d45a64c6142250cfcb42a3342b339de50f3c51882a3a46c9fafbb3ade1fccadc"}, + {file = "onepassword_sdk-0.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:6d892d1e6ca891f34328864fb1b5cfe2c28d1b9db95001a3c6468eb09befafc2"}, + {file = "onepassword_sdk-0.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1758497e111cca1c69a6eec52cc74971fb12a95744ca426dfed8a143f9a19a30"}, + {file = "onepassword_sdk-0.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:44c8bd2764d650ea0d4bbf05d0ff9f67b27fb8378f82e14569792ab0c8981902"}, + {file = "onepassword_sdk-0.3.0-cp39-cp39-manylinux_2_32_aarch64.whl", hash = "sha256:2510c3d569d0d087677507d934d77b740eb8150fa0a81c434fcd02964ff2f7c0"}, + {file = "onepassword_sdk-0.3.0-cp39-cp39-manylinux_2_32_x86_64.whl", hash = "sha256:eef5676c615c5fe987d26e29e7d60e08c3362ee3a4ec8ef2994f3275c6f3ed64"}, + {file = "onepassword_sdk-0.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:83bb1c3e80ad1c6d216247dc51f3d6ae1afdef33bb31671267237ee6fbc2c297"}, + {file = "onepassword_sdk-0.3.0.tar.gz", hash = "sha256:f6e2223cf67cdd07e15f06b61818386d8dcd8a1b54d20e8bf08ed48306479865"}, +] + +[package.dependencies] +pydantic = ">=2.5" + [[package]] name = "onnxruntime" version = "1.16.3" @@ -8720,4 +8759,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.11,<3.14" -content-hash = "21275cab7e1d76046abc646983184af8c9d02afae1c7950eaf1c2592b2cca129" +content-hash = "42bde1cb27d171911f966360781659a19ef5df896a220161c7d780c05a6a4758" diff --git a/pyproject.toml b/pyproject.toml index a8233c3b..dbe43b45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,6 +73,7 @@ anthropic = "^0.50.0" google-cloud-aiplatform = "^1.90.0" alive-progress = "^3.2.0" colorama = "^0.4.6" +onepassword-sdk = "0.3.0" types-boto3 = {extras = ["full"], version = "^1.38.31"} [tool.poetry.group.dev.dependencies] diff --git a/skyvern/config.py b/skyvern/config.py index 06f8c8c1..7324bc4e 100644 --- a/skyvern/config.py +++ b/skyvern/config.py @@ -242,6 +242,7 @@ class Settings(BaseSettings): BITWARDEN_CLIENT_ID: str | None = None BITWARDEN_CLIENT_SECRET: str | None = None BITWARDEN_MASTER_PASSWORD: str | None = None + OP_SERVICE_ACCOUNT_TOKEN: str | None = None # Skyvern Auth Bitwarden Settings SKYVERN_AUTH_BITWARDEN_CLIENT_ID: str | None = None diff --git a/skyvern/forge/sdk/db/client.py b/skyvern/forge/sdk/db/client.py index e1918625..05772dfe 100644 --- a/skyvern/forge/sdk/db/client.py +++ b/skyvern/forge/sdk/db/client.py @@ -22,6 +22,7 @@ from skyvern.forge.sdk.db.models import ( BitwardenSensitiveInformationParameterModel, CredentialModel, CredentialParameterModel, + OnePasswordCredentialParameterModel, OrganizationAuthTokenModel, OrganizationBitwardenCollectionModel, OrganizationModel, @@ -80,6 +81,7 @@ from skyvern.forge.sdk.workflow.models.parameter import ( BitwardenLoginCredentialParameter, BitwardenSensitiveInformationParameter, CredentialParameter, + OnePasswordCredentialParameter, OutputParameter, WorkflowParameter, WorkflowParameterType, @@ -1904,6 +1906,32 @@ class AgentDB: deleted_at=credential_parameter.deleted_at, ) + async def create_onepassword_credential_parameter( + self, workflow_id: str, key: str, vault_id: str, item_id: str, description: str | None = None + ) -> OnePasswordCredentialParameter: + async with self.Session() as session: + parameter = OnePasswordCredentialParameterModel( + workflow_id=workflow_id, + key=key, + description=description, + vault_id=vault_id, + item_id=item_id, + ) + session.add(parameter) + await session.commit() + await session.refresh(parameter) + return OnePasswordCredentialParameter( + onepassword_credential_parameter_id=parameter.onepassword_credential_parameter_id, + workflow_id=parameter.workflow_id, + key=parameter.key, + description=parameter.description, + vault_id=parameter.vault_id, + item_id=parameter.item_id, + created_at=parameter.created_at, + modified_at=parameter.modified_at, + deleted_at=parameter.deleted_at, + ) + async def get_workflow_run_output_parameters(self, workflow_run_id: str) -> list[WorkflowRunOutputParameter]: try: async with self.Session() as session: diff --git a/skyvern/forge/sdk/db/id.py b/skyvern/forge/sdk/db/id.py index 08ac1d61..70ded104 100644 --- a/skyvern/forge/sdk/db/id.py +++ b/skyvern/forge/sdk/db/id.py @@ -34,6 +34,7 @@ AWS_SECRET_PARAMETER_PREFIX = "asp" BITWARDEN_CREDIT_CARD_DATA_PARAMETER_PREFIX = "bccd" BITWARDEN_LOGIN_CREDENTIAL_PARAMETER_PREFIX = "blc" BITWARDEN_SENSITIVE_INFORMATION_PARAMETER_PREFIX = "bsi" +CREDENTIAL_ONEPASSWORD_PARAMETER_PREFIX = "opp" CREDENTIAL_PARAMETER_PREFIX = "cp" CREDENTIAL_PREFIX = "cred" ORGANIZATION_BITWARDEN_COLLECTION_PREFIX = "obc" @@ -106,6 +107,11 @@ def generate_bitwarden_credit_card_data_parameter_id() -> str: return f"{BITWARDEN_CREDIT_CARD_DATA_PARAMETER_PREFIX}_{int_id}" +def generate_onepassword_credential_parameter_id() -> str: + int_id = generate_id() + return f"{CREDENTIAL_ONEPASSWORD_PARAMETER_PREFIX}_{int_id}" + + def generate_organization_auth_token_id() -> str: int_id = generate_id() return f"{ORGANIZATION_AUTH_TOKEN_PREFIX}_{int_id}" diff --git a/skyvern/forge/sdk/db/models.py b/skyvern/forge/sdk/db/models.py index 55e891d3..a5cbf913 100644 --- a/skyvern/forge/sdk/db/models.py +++ b/skyvern/forge/sdk/db/models.py @@ -29,6 +29,7 @@ from skyvern.forge.sdk.db.id import ( generate_bitwarden_sensitive_information_parameter_id, generate_credential_id, generate_credential_parameter_id, + generate_onepassword_credential_parameter_id, generate_org_id, generate_organization_auth_token_id, generate_organization_bitwarden_collection_id, @@ -416,6 +417,28 @@ class CredentialParameterModel(Base): deleted_at = Column(DateTime, nullable=True) +class OnePasswordCredentialParameterModel(Base): + __tablename__ = "onepassword_credential_parameters" + + onepassword_credential_parameter_id = Column( + String, primary_key=True, index=True, default=generate_onepassword_credential_parameter_id + ) + workflow_id = Column(String, index=True, nullable=False) + key = Column(String, nullable=False) + description = Column(String, nullable=True) + vault_id = Column(String, nullable=False) + item_id = Column(String, nullable=False) + + created_at = Column(DateTime, default=datetime.datetime.utcnow, nullable=False) + modified_at = Column( + DateTime, + default=datetime.datetime.utcnow, + onupdate=datetime.datetime.utcnow, + nullable=False, + ) + deleted_at = Column(DateTime, nullable=True) + + class WorkflowRunParameterModel(Base): __tablename__ = "workflow_run_parameters" diff --git a/skyvern/forge/sdk/services/credentials.py b/skyvern/forge/sdk/services/credentials.py new file mode 100644 index 00000000..a4d23653 --- /dev/null +++ b/skyvern/forge/sdk/services/credentials.py @@ -0,0 +1,191 @@ +import json +import logging +from enum import StrEnum +from typing import Optional + +from onepassword.client import Client as OnePasswordClient + +from skyvern.config import settings + +LOG = logging.getLogger(__name__) + + +class OnePasswordConstants(StrEnum): + """Constants for 1Password integration.""" + + TOTP = "OP_TOTP" # Special value to indicate a TOTP code + + +async def resolve_secret(vault_id: str, item_id: str) -> str: + """ + Resolve a 1Password secret using vault_id and item_id directly. + + Args: + vault_id: The 1Password vault ID + item_id: The 1Password item ID + + Returns: + The resolved secret value + """ + token = settings.OP_SERVICE_ACCOUNT_TOKEN + if not token: + raise ValueError("OP_SERVICE_ACCOUNT_TOKEN not configured in settings") + + client = await OnePasswordClient.authenticate( + auth=token, + integration_name="Skyvern 1Password", + integration_version="v1.0.0", + ) + + result = await get_1password_item_details(client, vault_id, item_id) + return result + + +async def get_1password_item_details(client: OnePasswordClient, vault_id: str, item_id: str) -> str: + """ + Get details of a 1Password item. + + Args: + client: Authenticated 1Password client + vault_id: The vault ID + item_id: The item ID + + Returns: + JSON string containing item fields and their values + """ + try: + item = await client.items.get(vault_id, item_id) + + # Check if item is None + if item is None: + LOG.error(f"No item found for vault_id:{vault_id}, item_id:{item_id}") + raise ValueError(f"1Password item not found: vault_id:{vault_id}, item_id:{item_id}") + + # Create a dictionary of all fields + result = {} + + # Debug: Log the structure of the item and fields + LOG.info( + f"1Password item structure: {dir(item)}" + + (f"\nFirst field structure: {dir(item.fields[0])}" if hasattr(item, "fields") and item.fields else "") + ) + # We don't log field values as they may contain sensitive credentials + + # Add all fields with proper attribute checking + for i, field in enumerate(item.fields): + # Debug: Log each field's structure + LOG.debug(f"Field {i} structure: {dir(field)}") + + if hasattr(field, "value") and field.value is not None: + # Safely get field identifier - use id attribute or fallback to a default + try: + # Try different possible attribute names for the field identifier + field_id = None + + # Check all available attributes on the field object + field_attrs = dir(field) + LOG.debug(f"Field {i} attributes: {field_attrs}") + + # Try to get the most appropriate identifier + if hasattr(field, "id") and field.id: + field_id = field.id + LOG.debug(f"Using field.id: {field_id}") + elif hasattr(field, "name") and field.name: + field_id = field.name + LOG.debug(f"Using field.name: {field_id}") + elif hasattr(field, "label") and field.label: + field_id = field.label + LOG.debug(f"Using field.label: {field_id}") + elif hasattr(field, "type") and field.type: + field_id = f"{field.type}_{i}" + LOG.debug(f"Using field.type: {field_id}") + else: + # If no identifier found, generate one based on index + field_id = f"field_{i}" + LOG.debug(f"Using generated id: {field_id}") + + # Create a safe key name + key = str(field_id).lower().replace(" ", "_") + result[key] = field.value + LOG.debug(f"Added field with key '{key}' and value type: {type(field.value).__name__}") + + except Exception as field_err: + LOG.warning(f"Error processing field {i}: {field_err}") + # Still try to capture the value with a generic key + result[f"field_{i}"] = field.value + + # Explicitly look for username and password fields + for i, field in enumerate(item.fields): + try: + # Check for username field using various possible attributes + if "username" not in result: + if hasattr(field, "id") and field.id == "username" and hasattr(field, "value") and field.value: + result["username"] = field.value + LOG.debug(f"Found username field at index {i}") + elif ( + hasattr(field, "purpose") + and field.purpose == "USERNAME" + and hasattr(field, "value") + and field.value + ): + result["username"] = field.value + LOG.debug(f"Found username field by purpose at index {i}") + elif ( + hasattr(field, "type") and field.type == "USERNAME" and hasattr(field, "value") and field.value + ): + result["username"] = field.value + LOG.debug(f"Found username field by type at index {i}") + + # Check for password field using various possible attributes + if "password" not in result: + if hasattr(field, "id") and field.id == "password" and hasattr(field, "value") and field.value: + result["password"] = field.value + LOG.debug(f"Found password field at index {i}") + elif ( + hasattr(field, "purpose") + and field.purpose == "PASSWORD" + and hasattr(field, "value") + and field.value + ): + result["password"] = field.value + LOG.debug(f"Found password field by purpose at index {i}") + elif ( + hasattr(field, "type") and field.type == "PASSWORD" and hasattr(field, "value") and field.value + ): + result["password"] = field.value + LOG.debug(f"Found password field by type at index {i}") + except Exception as field_err: + LOG.warning(f"Error processing username/password field at index {i}: {field_err}") + + # Add TOTP if available + try: + totp = await get_totp_for_item(client, vault_id, item_id) + if totp: + result["totp"] = totp + except Exception as totp_err: + LOG.warning(f"Error getting TOTP: {totp_err}") + + return json.dumps(result) + except Exception as e: + LOG.error(f"Error retrieving 1Password item {vault_id}:{item_id}: {str(e)}") + raise + + +async def get_totp_for_item(client: OnePasswordClient, vault_id: str, item_id: str) -> Optional[str]: + """ + Get the TOTP code for a 1Password item if available. + + Args: + client: Authenticated 1Password client + vault_id: The vault ID + item_id: The item ID + + Returns: + TOTP code if available, None otherwise + """ + try: + totp = await client.items.get_totp(vault_id, item_id) + return totp + except Exception: + # TOTP might not be available for this item + return None diff --git a/skyvern/forge/sdk/workflow/context_manager.py b/skyvern/forge/sdk/workflow/context_manager.py index 573eb339..3ef53788 100644 --- a/skyvern/forge/sdk/workflow/context_manager.py +++ b/skyvern/forge/sdk/workflow/context_manager.py @@ -1,8 +1,10 @@ import copy +import json import uuid from typing import TYPE_CHECKING, Any, Self import structlog +from onepassword.client import Client as OnePasswordClient from skyvern.config import settings from skyvern.exceptions import ( @@ -17,6 +19,7 @@ from skyvern.forge.sdk.schemas.credentials import PasswordCredential from skyvern.forge.sdk.schemas.organizations import Organization from skyvern.forge.sdk.schemas.tasks import TaskStatus from skyvern.forge.sdk.services.bitwarden import BitwardenConstants, BitwardenService +from skyvern.forge.sdk.services.credentials import OnePasswordConstants, resolve_secret from skyvern.forge.sdk.workflow.exceptions import OutputParameterKeyCollisionError from skyvern.forge.sdk.workflow.models.parameter import ( PARAMETER_TYPE, @@ -26,6 +29,7 @@ from skyvern.forge.sdk.workflow.models.parameter import ( BitwardenSensitiveInformationParameter, ContextParameter, CredentialParameter, + OnePasswordCredentialParameter, OutputParameter, Parameter, ParameterType, @@ -86,6 +90,8 @@ class WorkflowRunContext: await workflow_run_context.register_aws_secret_parameter_value(aws_client, secrete_parameter) elif isinstance(secrete_parameter, CredentialParameter): await workflow_run_context.register_credential_parameter_value(secrete_parameter, organization) + elif isinstance(secrete_parameter, OnePasswordCredentialParameter): + await workflow_run_context.register_onepassword_credential_parameter_value(secrete_parameter) elif isinstance(secrete_parameter, BitwardenLoginCredentialParameter): await workflow_run_context.register_bitwarden_login_credential_parameter_value( aws_client, secrete_parameter, organization @@ -180,6 +186,29 @@ class WorkflowRunContext: def generate_random_secret_id() -> str: return f"secret_{uuid.uuid4()}" + async def _get_credential_vault_and_item_ids(self, credential_id: str) -> tuple[str, str]: + """ + Extract vault_id and item_id from the credential_id. + This method handles the legacy format vault_id:item_id. + + Args: + credential_id: The credential identifier in the format vault_id:item_id + + Returns: + A tuple of (vault_id, item_id) + + Raises: + ValueError: If the credential format is invalid + """ + # Check if it's in the format vault_id:item_id + if ":" in credential_id: + LOG.info(f"Processing credential in vault_id:item_id format: {credential_id}") + vault_id, item_id = credential_id.split(":", 1) + return vault_id, item_id + + # If we can't parse the credential_id, raise an error + raise ValueError(f"Invalid credential format: {credential_id}. Expected format: vault_id:item_id") + async def register_secret_workflow_parameter_value( self, parameter: WorkflowParameter, @@ -195,30 +224,108 @@ class WorkflowRunContext: LOG.info(f"Fetching credential parameter value for credential: {credential_id}") - db_credential = await app.DATABASE.get_credential(credential_id, organization_id=organization.organization_id) - if db_credential is None: - raise CredentialParameterNotFoundError(credential_id) + try: + # Extract vault_id and item_id from the database + vault_id, item_id = await self._get_credential_vault_and_item_ids(credential_id) - bitwarden_credential = await BitwardenService.get_credential_item(db_credential.item_id) + # Use the 1Password SDK to resolve the reference using vault_id and item_id directly + secret_value_json = await resolve_secret(vault_id, item_id) - credential_item = bitwarden_credential.credential + # Validate the JSON response + if not secret_value_json: + LOG.error(f"Empty response from 1Password for credential: {credential_id}") + raise ValueError(f"Empty response from 1Password for credential: {credential_id}") - self.parameters[parameter.key] = parameter - self.values[parameter.key] = {} - credential_dict = credential_item.model_dump() - for key, value in credential_dict.items(): - random_secret_id = self.generate_random_secret_id() - secret_id = f"{random_secret_id}_{key}" - self.secrets[secret_id] = value - self.values[parameter.key][key] = secret_id + try: + secret_values = json.loads(secret_value_json) + except json.JSONDecodeError as json_err: + LOG.error(f"Invalid JSON response from 1Password: {secret_value_json[:100]}... Error: {json_err}") + raise ValueError(f"Invalid JSON response from 1Password: {json_err}") - if isinstance(credential_item, PasswordCredential) and credential_item.totp is not None: - random_secret_id = self.generate_random_secret_id() - totp_secret_id = f"{random_secret_id}_totp" - self.secrets[totp_secret_id] = BitwardenConstants.TOTP - totp_secret_value = self.totp_secret_value_key(totp_secret_id) - self.secrets[totp_secret_value] = credential_item.totp - self.values[parameter.key]["totp"] = totp_secret_id + if not secret_values: + LOG.warning(f"No values found in 1Password item: {credential_id}") + # Still continue with empty values + + self.parameters[parameter.key] = parameter + self.values[parameter.key] = {} + + # Process fields from the 1Password item + if "fields" in secret_values and isinstance(secret_values["fields"], list): + for field in secret_values["fields"]: + if not isinstance(field, dict) or "id" not in field or "value" not in field: + continue + + field_id = field.get("id") + field_type = field.get("field_type") + field_value = field.get("value") + + # Store the field value + random_secret_id = self.generate_random_secret_id() + secret_id = f"{random_secret_id}_{field_id}" + self.secrets[secret_id] = field_value + self.values[parameter.key][field_id] = secret_id + + # For TOTP fields, also store the current code + if field_type == "Totp" and isinstance(field.get("details"), dict): + details = field.get("details") + # Explicitly check that details is a dict before accessing get method + if isinstance(details, dict): + content = details.get("content") + if isinstance(content, dict) and "code" in content: + totp_code = content["code"] + random_secret_id = self.generate_random_secret_id() + totp_secret_id = f"{random_secret_id}_totp" + self.secrets[totp_secret_id] = totp_code + totp_secret_value = self.totp_secret_value_key(totp_secret_id) + self.secrets[totp_secret_value] = field_value # Store the TOTP secret + self.values[parameter.key]["totp"] = totp_secret_id + else: + # Process each field in the 1Password item (old format or custom format) + for key, value in secret_values.items(): + random_secret_id = self.generate_random_secret_id() + secret_id = f"{random_secret_id}_{key}" + self.secrets[secret_id] = value + self.values[parameter.key][key] = secret_id + + LOG.info("Successfully processed 1Password credential") + return + + except Exception as e: + LOG.error(f"Failed to process 1Password credential: {credential_id}. Error: {str(e)}") + # Add more context to the error + raise ValueError(f"Failed to process 1Password credential {credential_id}: {str(e)}") from e + + # Handle regular credentials from the database + try: + db_credential = await app.DATABASE.get_credential( + credential_id, organization_id=organization.organization_id + ) + if db_credential is None: + raise CredentialParameterNotFoundError(credential_id) + + bitwarden_credential = await BitwardenService.get_credential_item(db_credential.item_id) + + credential_item = bitwarden_credential.credential + + self.parameters[parameter.key] = parameter + self.values[parameter.key] = {} + credential_dict = credential_item.model_dump() + for key, value in credential_dict.items(): + random_secret_id = self.generate_random_secret_id() + secret_id = f"{random_secret_id}_{key}" + self.secrets[secret_id] = value + self.values[parameter.key][key] = secret_id + + if isinstance(credential_item, PasswordCredential) and credential_item.totp is not None: + random_secret_id = self.generate_random_secret_id() + totp_secret_id = f"{random_secret_id}_totp" + self.secrets[totp_secret_id] = BitwardenConstants.TOTP + totp_secret_value = self.totp_secret_value_key(totp_secret_id) + self.secrets[totp_secret_value] = credential_item.totp + self.values[parameter.key]["totp"] = totp_secret_id + except Exception as e: + LOG.error(f"Failed to get credential from database: {credential_id}. Error: {e}") + raise e async def register_credential_parameter_value( self, @@ -278,6 +385,56 @@ class WorkflowRunContext: self.values[parameter.key] = random_secret_id self.parameters[parameter.key] = parameter + async def register_onepassword_credential_parameter_value(self, parameter: OnePasswordCredentialParameter) -> None: + token = settings.OP_SERVICE_ACCOUNT_TOKEN + if not token: + raise ValueError("OP_SERVICE_ACCOUNT_TOKEN environment variable not set") + + client = await OnePasswordClient.authenticate( + auth=token, + integration_name="Skyvern", + integration_version="v1.0.0", + ) + + item = await client.items.get(parameter.vault_id, parameter.item_id) + + # Check if item is None + if item is None: + LOG.error(f"No item found for vault_id:{parameter.vault_id}, item_id:{parameter.item_id}") + raise ValueError(f"1Password item not found: vault_id:{parameter.vault_id}, item_id:{parameter.item_id}") + + self.parameters[parameter.key] = parameter + self.values[parameter.key] = {} + + # Process all fields + for field in item.fields: + if field.value is None: + continue + random_secret_id = self.generate_random_secret_id() + secret_id = f"{random_secret_id}_{field.id}" + self.secrets[secret_id] = field.value + key = (field.label or field.id).lower().replace(" ", "_") + self.values[parameter.key][key] = secret_id + + # Try to get TOTP if available + try: + totp = await client.items.get_totp(parameter.vault_id, parameter.item_id) + if totp: + # Store the actual TOTP value in a separate secret for internal use + random_secret_id = self.generate_random_secret_id() + totp_value_id = f"{random_secret_id}_totp_value" + self.secrets[totp_value_id] = totp + + # Store the special TOTP constant that the agent will recognize + totp_secret_id = f"{random_secret_id}_totp" + self.secrets[totp_secret_id] = OnePasswordConstants.TOTP + self.values[parameter.key]["totp"] = totp_secret_id + + LOG.info(f"TOTP code available for item {parameter.item_id}") + except Exception as e: + # TOTP might not be available for this item, just log and continue + LOG.debug(f"TOTP not available for item {parameter.item_id}: {str(e)}") + async def register_bitwarden_login_credential_parameter_value( self, aws_client: AsyncAWSClient, diff --git a/skyvern/forge/sdk/workflow/models/parameter.py b/skyvern/forge/sdk/workflow/models/parameter.py index 3c59b3f2..56862ade 100644 --- a/skyvern/forge/sdk/workflow/models/parameter.py +++ b/skyvern/forge/sdk/workflow/models/parameter.py @@ -18,6 +18,7 @@ class ParameterType(StrEnum): BITWARDEN_LOGIN_CREDENTIAL = "bitwarden_login_credential" BITWARDEN_SENSITIVE_INFORMATION = "bitwarden_sensitive_information" BITWARDEN_CREDIT_CARD_DATA = "bitwarden_credit_card_data" + ONEPASSWORD = "onepassword" OUTPUT = "output" CREDENTIAL = "credential" @@ -127,6 +128,19 @@ class BitwardenCreditCardDataParameter(Parameter): deleted_at: datetime | None = None +class OnePasswordCredentialParameter(Parameter): + parameter_type: Literal[ParameterType.ONEPASSWORD] = ParameterType.ONEPASSWORD + + onepassword_credential_parameter_id: str + workflow_id: str + vault_id: str + item_id: str + + created_at: datetime + modified_at: datetime + deleted_at: datetime | None = None + + class WorkflowParameterType(StrEnum): STRING = "string" INTEGER = "integer" @@ -203,6 +217,7 @@ ParameterSubclasses = Union[ BitwardenLoginCredentialParameter, BitwardenSensitiveInformationParameter, BitwardenCreditCardDataParameter, + OnePasswordCredentialParameter, OutputParameter, CredentialParameter, ] diff --git a/skyvern/forge/sdk/workflow/models/yaml.py b/skyvern/forge/sdk/workflow/models/yaml.py index 772fa065..a96cb38a 100644 --- a/skyvern/forge/sdk/workflow/models/yaml.py +++ b/skyvern/forge/sdk/workflow/models/yaml.py @@ -86,6 +86,12 @@ class BitwardenCreditCardDataParameterYAML(ParameterYAML): bitwarden_item_id: str +class OnePasswordCredentialParameterYAML(ParameterYAML): + parameter_type: Literal[ParameterType.ONEPASSWORD] = ParameterType.ONEPASSWORD # type: ignore + vault_id: str + item_id: str + + class WorkflowParameterYAML(ParameterYAML): # There is a mypy bug with Literal. Without the type: ignore, mypy will raise an error: # Parameter 1 of Literal[...] cannot be of type "Any" @@ -370,6 +376,7 @@ PARAMETER_YAML_SUBCLASSES = ( | BitwardenLoginCredentialParameterYAML | BitwardenSensitiveInformationParameterYAML | BitwardenCreditCardDataParameterYAML + | OnePasswordCredentialParameterYAML | WorkflowParameterYAML | ContextParameterYAML | OutputParameterYAML diff --git a/skyvern/forge/sdk/workflow/service.py b/skyvern/forge/sdk/workflow/service.py index 65fa7e00..f8e9b5ca 100644 --- a/skyvern/forge/sdk/workflow/service.py +++ b/skyvern/forge/sdk/workflow/service.py @@ -68,6 +68,7 @@ from skyvern.forge.sdk.workflow.models.parameter import ( BitwardenSensitiveInformationParameter, ContextParameter, CredentialParameter, + OnePasswordCredentialParameter, OutputParameter, Parameter, ParameterType, @@ -239,6 +240,7 @@ class WorkflowService: BitwardenLoginCredentialParameter, BitwardenCreditCardDataParameter, BitwardenSensitiveInformationParameter, + OnePasswordCredentialParameter, CredentialParameter, ), ) @@ -883,6 +885,22 @@ class WorkflowService: description=description, ) + async def create_onepassword_credential_parameter( + self, + workflow_id: str, + key: str, + vault_id: str, + item_id: str, + description: str | None = None, + ) -> OnePasswordCredentialParameter: + return await app.DATABASE.create_onepassword_credential_parameter( + workflow_id=workflow_id, + key=key, + vault_id=vault_id, + item_id=item_id, + description=description, + ) + async def create_bitwarden_sensitive_information_parameter( self, workflow_id: str, @@ -1490,6 +1508,14 @@ class WorkflowService: description=parameter.description, credential_id=parameter.credential_id, ) + elif parameter.parameter_type == ParameterType.ONEPASSWORD: + parameters[parameter.key] = await self.create_onepassword_credential_parameter( + workflow_id=workflow.workflow_id, + key=parameter.key, + description=parameter.description, + vault_id=parameter.vault_id, + item_id=parameter.item_id, + ) elif parameter.parameter_type == ParameterType.BITWARDEN_LOGIN_CREDENTIAL: if not parameter.bitwarden_collection_id and not parameter.bitwarden_item_id: raise WorkflowParameterMissingRequiredValue( diff --git a/skyvern/webeye/actions/handler.py b/skyvern/webeye/actions/handler.py index 25daa26d..b11cbe48 100644 --- a/skyvern/webeye/actions/handler.py +++ b/skyvern/webeye/actions/handler.py @@ -70,6 +70,7 @@ from skyvern.forge.sdk.db.enums import OrganizationAuthTokenType from skyvern.forge.sdk.models import Step from skyvern.forge.sdk.schemas.tasks import Task from skyvern.forge.sdk.services.bitwarden import BitwardenConstants +from skyvern.forge.sdk.services.credentials import OnePasswordConstants from skyvern.schemas.runs import CUA_RUN_TYPES from skyvern.utils.prompt_engine import CheckPhoneNumberFormatResponse, load_prompt_with_elements from skyvern.webeye.actions import actions @@ -821,7 +822,7 @@ async def handle_input_text_action( if text is None: return [ActionFailure(FailedToFetchSecret())] - is_totp_value = text == BitwardenConstants.TOTP + is_totp_value = text == BitwardenConstants.TOTP or text == OnePasswordConstants.TOTP is_secret_value = text != action.text # dynamically validate the attr, since it could change into enabled after the previous actions