From 209d715a4a813234583dfe7f8503c1ef8ceed714 Mon Sep 17 00:00:00 2001 From: Shuchang Zheng Date: Tue, 16 Jun 2026 13:48:17 -0700 Subject: [PATCH] GCP Self-Host: App-wiring for storage and vault (#6632) Co-authored-by: Aaron Perez --- .env.example | 24 ++++++++++++++ skyvern/cli/mcp_tools/browser.py | 4 +-- skyvern/forge/agent.py | 8 +++-- skyvern/forge/forge_app.py | 19 ++++++++++++ skyvern/forge/sdk/api/files.py | 12 +++---- skyvern/forge/sdk/api/real_gcp.py | 10 ++++++ skyvern/forge/sdk/routes/credentials.py | 4 +++ skyvern/forge/sdk/workflow/models/block.py | 6 ++-- .../test_credential_vault_type_override.py | 31 +++++++++++++++++++ .../unit/test_file_download_access_control.py | 28 +++++++++++++++++ tests/unit/test_local_file_path_validation.py | 3 ++ 11 files changed, 137 insertions(+), 12 deletions(-) diff --git a/.env.example b/.env.example index 3ae199976..ec2aa6a95 100644 --- a/.env.example +++ b/.env.example @@ -179,3 +179,27 @@ SKYVERN_AUTH_BITWARDEN_CLIENT_SECRET=your-client-secret-here # Optional: override Redis URL specifically for notifications (falls back to REDIS_URL) # NOTIFICATION_REDIS_URL= # REDIS_URL=redis://localhost:6379/0 + +# ============================================================================= +# OPTIONAL: GCP SELF-HOSTED DEPLOYMENT +# ============================================================================= +# Uncomment to run Skyvern on Google Cloud with GCS storage and Secret Manager +# credentials. Auth uses Application Default Credentials (Workload Identity on +# GKE, or GOOGLE_APPLICATION_CREDENTIALS pointing at a service-account key). + +# Store artifacts/screenshots/browser sessions/uploads in Google Cloud Storage +# SKYVERN_STORAGE_TYPE=gcs +# GCS_PROJECT_ID=your-gcp-project-id +# GCS_BUCKET_ARTIFACTS=skyvern-artifacts +# GCS_BUCKET_SCREENSHOTS=skyvern-screenshots +# GCS_BUCKET_BROWSER_SESSIONS=skyvern-browser-sessions +# GCS_BUCKET_UPLOADS=skyvern-uploads +# Service account that signs GCS download URLs; required only under Workload +# Identity, where the runtime SA has no signing key of its own. +# GCS_SIGNER_SA_EMAIL=skyvern@your-gcp-project-id.iam.gserviceaccount.com + +# Store workflow credentials in GCP Secret Manager instead of Bitwarden +# CREDENTIAL_VAULT_TYPE=gcp +# GCP_CREDENTIAL_VAULT_PROJECT_ID=your-gcp-project-id +# Secret-id prefix; must be unique per Skyvern deployment sharing a project +# GCP_CREDENTIAL_VAULT_PREFIX=skyvern-cred- diff --git a/skyvern/cli/mcp_tools/browser.py b/skyvern/cli/mcp_tools/browser.py index 8a3a708e6..67c5405fb 100644 --- a/skyvern/cli/mcp_tools/browser.py +++ b/skyvern/cli/mcp_tools/browser.py @@ -450,8 +450,8 @@ async def skyvern_file_upload( ), ) - has_urls = any(fp.startswith(("http://", "https://", "s3://", "azure://")) for fp in file_paths) - has_local = any(not fp.startswith(("http://", "https://", "s3://", "azure://")) for fp in file_paths) + has_urls = any(fp.startswith(("http://", "https://", "s3://", "gs://", "azure://")) for fp in file_paths) + has_local = any(not fp.startswith(("http://", "https://", "s3://", "gs://", "azure://")) for fp in file_paths) if has_urls and has_local: return make_result( diff --git a/skyvern/forge/agent.py b/skyvern/forge/agent.py index bf75e1148..48df0e39c 100644 --- a/skyvern/forge/agent.py +++ b/skyvern/forge/agent.py @@ -88,6 +88,7 @@ from skyvern.forge.sdk.api.llm.ui_tars_llm_caller import UITarsLLMCaller from skyvern.forge.sdk.api.llm.vertex_cache_manager import get_cache_manager from skyvern.forge.sdk.api.llm.yutori_navigator_llm_caller import YutoriNavigatorLLMCaller from skyvern.forge.sdk.api.llm.yutori_navigator_response import parse_navigator_response_to_actions +from skyvern.forge.sdk.api.real_gcp import get_gcs_client from skyvern.forge.sdk.artifact.manager import BulkArtifactCreationRequest from skyvern.forge.sdk.artifact.models import ArtifactType from skyvern.forge.sdk.cache import extraction_cache, extraction_shadow @@ -416,8 +417,11 @@ class ForgeAgent: return [] for file in files_to_rename: local_file_name = file - if file.startswith("s3://"): - file_data = await get_aws_client().download_file(file, log_exception=False) + if file.startswith(("s3://", "gs://")): + if file.startswith("s3://"): + file_data = await get_aws_client().download_file(file, log_exception=False) + else: + file_data = await get_gcs_client().download_file(file, log_exception=False) if not file_data: continue local_file_name = file.split("/")[-1] diff --git a/skyvern/forge/forge_app.py b/skyvern/forge/forge_app.py index e2a36c886..4a999d95e 100644 --- a/skyvern/forge/forge_app.py +++ b/skyvern/forge/forge_app.py @@ -16,13 +16,16 @@ from skyvern.forge.agent_functions import AgentFunction from skyvern.forge.forge_openai_client import ForgeAsyncHttpxClientWrapper from skyvern.forge.sdk.api.azure import AzureClientFactory from skyvern.forge.sdk.api.custom_credential_client import CustomCredentialAPIClient +from skyvern.forge.sdk.api.gcp import GcpClientFactory from skyvern.forge.sdk.api.llm.api_handler import LLMAPIHandler from skyvern.forge.sdk.api.llm.api_handler_factory import LLMAPIHandlerFactory from skyvern.forge.sdk.api.real_azure import RealAzureClientFactory +from skyvern.forge.sdk.api.real_gcp import RealGcpClientFactory from skyvern.forge.sdk.artifact.manager import ArtifactManager from skyvern.forge.sdk.artifact.storage.azure import AzureStorage from skyvern.forge.sdk.artifact.storage.base import BaseStorage from skyvern.forge.sdk.artifact.storage.factory import StorageFactory +from skyvern.forge.sdk.artifact.storage.gcs import GcsStorage from skyvern.forge.sdk.artifact.storage.s3 import S3Storage from skyvern.forge.sdk.cache.base import BaseCache from skyvern.forge.sdk.cache.factory import CacheFactory @@ -37,6 +40,7 @@ from skyvern.forge.sdk.services.credential.azure_credential_vault_service import from skyvern.forge.sdk.services.credential.bitwarden_credential_service import BitwardenCredentialVaultService from skyvern.forge.sdk.services.credential.credential_vault_service import CredentialVaultService from skyvern.forge.sdk.services.credential.custom_credential_vault_service import CustomCredentialVaultService +from skyvern.forge.sdk.services.credential.gcp_credential_vault_service import GcpCredentialVaultService from skyvern.forge.sdk.settings_manager import SettingsManager from skyvern.forge.sdk.workflow.context_manager import WorkflowContextManager from skyvern.forge.sdk.workflow.service import WorkflowService @@ -69,6 +73,7 @@ class ForgeApp: UI_TARS_CLIENT: AsyncOpenAI | None YUTORI_CLIENT: AsyncYutoriClient | None AZURE_CLIENT_FACTORY: AzureClientFactory + GCP_CLIENT_FACTORY: GcpClientFactory SECONDARY_LLM_API_HANDLER: LLMAPIHandler SELECT_AGENT_LLM_API_HANDLER: LLMAPIHandler NORMAL_SELECT_AGENT_LLM_API_HANDLER: LLMAPIHandler @@ -92,6 +97,7 @@ class ForgeApp: BROWSER_SESSION_RECORDING_SERVICE: BrowserSessionRecordingService BITWARDEN_CREDENTIAL_VAULT_SERVICE: BitwardenCredentialVaultService AZURE_CREDENTIAL_VAULT_SERVICE: AzureCredentialVaultService | None + GCP_CREDENTIAL_VAULT_SERVICE: GcpCredentialVaultService | None CUSTOM_CREDENTIAL_VAULT_SERVICE: CustomCredentialVaultService | None CREDENTIAL_VAULT_SERVICES: dict[str, CredentialVaultService | None] scrape_exclude: ScrapeExcludeFunc | None @@ -122,6 +128,8 @@ def create_forge_app() -> ForgeApp: StorageFactory.set_storage(S3Storage()) elif settings.SKYVERN_STORAGE_TYPE == "azureblob": StorageFactory.set_storage(AzureStorage()) + elif settings.SKYVERN_STORAGE_TYPE == "gcs": + StorageFactory.set_storage(GcsStorage()) app.STORAGE = StorageFactory.get_storage() app.CACHE = CacheFactory.get_cache() @@ -274,6 +282,7 @@ def create_forge_app() -> ForgeApp: app.BROWSER_SESSION_RECORDING_SERVICE = BrowserSessionRecordingService() app.AZURE_CLIENT_FACTORY = RealAzureClientFactory() + app.GCP_CLIENT_FACTORY = RealGcpClientFactory() app.BITWARDEN_CREDENTIAL_VAULT_SERVICE = BitwardenCredentialVaultService() # Azure Credential Vault Service @@ -300,6 +309,15 @@ def create_forge_app() -> ForgeApp: ) else: app.AZURE_CREDENTIAL_VAULT_SERVICE = None + + if settings.GCP_CREDENTIAL_VAULT_PROJECT_ID: + app.GCP_CREDENTIAL_VAULT_SERVICE = GcpCredentialVaultService( + app.GCP_CLIENT_FACTORY.create_secret_manager_client(), + project_id=settings.GCP_CREDENTIAL_VAULT_PROJECT_ID, + ) + else: + app.GCP_CREDENTIAL_VAULT_SERVICE = None + app.CUSTOM_CREDENTIAL_VAULT_SERVICE = ( CustomCredentialVaultService( CustomCredentialAPIClient( @@ -313,6 +331,7 @@ def create_forge_app() -> ForgeApp: app.CREDENTIAL_VAULT_SERVICES = { CredentialVaultType.BITWARDEN: app.BITWARDEN_CREDENTIAL_VAULT_SERVICE, CredentialVaultType.AZURE_VAULT: app.AZURE_CREDENTIAL_VAULT_SERVICE, + CredentialVaultType.GCP: app.GCP_CREDENTIAL_VAULT_SERVICE, CredentialVaultType.CUSTOM: app.CUSTOM_CREDENTIAL_VAULT_SERVICE, } diff --git a/skyvern/forge/sdk/api/files.py b/skyvern/forge/sdk/api/files.py index 58bc6f87d..12db6ad06 100644 --- a/skyvern/forge/sdk/api/files.py +++ b/skyvern/forge/sdk/api/files.py @@ -122,7 +122,7 @@ def validate_download_url(url: str, organization_id: str | None = None) -> bool: if scheme in ("http", "https"): return True - if scheme in ("s3", "azure"): + if scheme in ("s3", "gs", "azure"): try: if organization_id is None: return False @@ -175,7 +175,7 @@ async def download_file( # Check if URL is a cloud storage URI handled by the configured storage backend. parsed = urlparse(url) - if parsed.scheme in ("s3", "azure"): + if parsed.scheme in ("s3", "gs", "azure"): if organization_id is None: raise PermissionError(f"No permission to access storage URI: {url}") @@ -272,11 +272,11 @@ def unzip_files(zip_file_path: str, output_dir: str) -> None: zip_ref.extractall(output_dir) -_REMOTE_URL_PREFIXES = ("http://", "https://", "s3://", "azure://", "www.") +_REMOTE_URL_PREFIXES = ("http://", "https://", "s3://", "gs://", "azure://", "www.") def is_remote_url(path: str) -> bool: - """Return True if the path is a remote URL (HTTP, S3, Azure) rather than a local filesystem path.""" + """Return True if the path is a remote URL (HTTP, S3, GCS, Azure) rather than a local filesystem path.""" return path.startswith(_REMOTE_URL_PREFIXES) @@ -351,9 +351,9 @@ async def wait_for_download_finished(downloading_files: list[str], timeout: floa while len(cur_downloading_files) > 0: new_downloading_files: list[str] = [] for path in cur_downloading_files: - # Check for cloud storage URIs (S3 or Azure) + # Check for cloud storage URIs (S3, GCS, or Azure) parsed = urlparse(path) - if parsed.scheme in ("s3", "azure"): + if parsed.scheme in ("s3", "gs", "azure"): if not await app.STORAGE.file_exists(path): LOG.debug( "downloading file is not found in cloud storage, means the file finished downloading", diff --git a/skyvern/forge/sdk/api/real_gcp.py b/skyvern/forge/sdk/api/real_gcp.py index 70b3d9db1..5f8a9d506 100644 --- a/skyvern/forge/sdk/api/real_gcp.py +++ b/skyvern/forge/sdk/api/real_gcp.py @@ -332,3 +332,13 @@ class RealGcpClientFactory(GcpClientFactory): def create_secret_manager_client(self) -> AsyncGcpSecretManagerClient: return RealAsyncGcpSecretManagerClient() + + +_gcs_client: RealAsyncGcsStorageClient | None = None + + +def get_gcs_client() -> RealAsyncGcsStorageClient: + global _gcs_client + if _gcs_client is None: + _gcs_client = RealAsyncGcsStorageClient(project_id=settings.GCS_PROJECT_ID) + return _gcs_client diff --git a/skyvern/forge/sdk/routes/credentials.py b/skyvern/forge/sdk/routes/credentials.py index 6de0dcdf0..ff59214a3 100644 --- a/skyvern/forge/sdk/routes/credentials.py +++ b/skyvern/forge/sdk/routes/credentials.py @@ -2341,6 +2341,10 @@ async def _get_credential_vault_service( if not app.AZURE_CREDENTIAL_VAULT_SERVICE: raise HTTPException(status_code=400, detail="Azure Vault credential is not supported") return app.AZURE_CREDENTIAL_VAULT_SERVICE + elif vault_type == CredentialVaultType.GCP: + if not app.GCP_CREDENTIAL_VAULT_SERVICE: + raise HTTPException(status_code=400, detail="GCP credential vault is not supported") + return app.GCP_CREDENTIAL_VAULT_SERVICE elif vault_type == CredentialVaultType.CUSTOM: if not app.CUSTOM_CREDENTIAL_VAULT_SERVICE: raise HTTPException(status_code=400, detail="Custom credential vault is not supported") diff --git a/skyvern/forge/sdk/workflow/models/block.py b/skyvern/forge/sdk/workflow/models/block.py index db21cbf7a..e5ae35ae0 100644 --- a/skyvern/forge/sdk/workflow/models/block.py +++ b/skyvern/forge/sdk/workflow/models/block.py @@ -5136,7 +5136,7 @@ class SendEmailBlock(Block): file_names_by_hash: dict[str, list[str]] = defaultdict(list) for filename in self._get_file_paths(workflow_run_context, workflow_run_id): - if filename.startswith(("s3://", "azure://", "http://", "https://")): + if filename.startswith(("s3://", "gs://", "azure://", "http://", "https://")): path = await download_file(filename, organization_id=organization_id) else: LOG.info("SendEmailBlock Looking for file locally", filename=filename) @@ -6887,7 +6887,9 @@ class HttpRequestBlock(Block): is_url = ( file_path.startswith("http://") or file_path.startswith("https://") or file_path.startswith("www.") ) - is_managed_storage_uri = file_path.startswith("s3://") or file_path.startswith("azure://") + is_managed_storage_uri = ( + file_path.startswith("s3://") or file_path.startswith("gs://") or file_path.startswith("azure://") + ) # Check if file is in allowed directories is_allowed_local_file = False diff --git a/tests/unit/test_credential_vault_type_override.py b/tests/unit/test_credential_vault_type_override.py index 95e30414c..f0ce51d87 100644 --- a/tests/unit/test_credential_vault_type_override.py +++ b/tests/unit/test_credential_vault_type_override.py @@ -177,6 +177,37 @@ class TestGetCredentialVaultServiceRouting: ) assert result is mock_azure + @pytest.mark.asyncio + async def test_override_gcp_ignores_global(self) -> None: + mock_bw = MagicMock(spec=CredentialVaultService) + mock_gcp = MagicMock(spec=CredentialVaultService) + with ( + patch("skyvern.forge.sdk.routes.credentials.settings") as mock_settings, + patch("skyvern.forge.sdk.routes.credentials.app") as mock_app, + ): + mock_settings.CREDENTIAL_VAULT_TYPE = CredentialVaultType.BITWARDEN + mock_app.BITWARDEN_CREDENTIAL_VAULT_SERVICE = mock_bw + mock_app.GCP_CREDENTIAL_VAULT_SERVICE = mock_gcp + result = await _get_credential_vault_service( + vault_type_override=CredentialVaultType.GCP, + ) + assert result is mock_gcp + + @pytest.mark.asyncio + async def test_override_gcp_raises_when_not_configured(self) -> None: + with ( + patch("skyvern.forge.sdk.routes.credentials.settings") as mock_settings, + patch("skyvern.forge.sdk.routes.credentials.app") as mock_app, + ): + mock_settings.CREDENTIAL_VAULT_TYPE = CredentialVaultType.BITWARDEN + mock_app.GCP_CREDENTIAL_VAULT_SERVICE = None + with pytest.raises(HTTPException) as exc_info: + await _get_credential_vault_service( + vault_type_override=CredentialVaultType.GCP, + ) + assert exc_info.value.status_code == 400 + assert "GCP credential vault" in str(exc_info.value.detail) + @pytest.mark.asyncio async def test_override_custom_raises_when_not_configured(self) -> None: with ( diff --git a/tests/unit/test_file_download_access_control.py b/tests/unit/test_file_download_access_control.py index 2995ac682..905563751 100644 --- a/tests/unit/test_file_download_access_control.py +++ b/tests/unit/test_file_download_access_control.py @@ -32,6 +32,10 @@ def _artifact_s3_uri(organization_id: str) -> str: ) +def _legacy_gcs_uri(organization_id: str) -> str: + return f"gs://{settings.GCS_BUCKET_UPLOADS}/{settings.ENV}/{organization_id}/secret.pdf" + + @pytest.fixture(autouse=True) def storage(monkeypatch: pytest.MonkeyPatch) -> SimpleNamespace: def assert_managed_file_access(uri: str, organization_id: str) -> None: @@ -39,6 +43,7 @@ def storage(monkeypatch: pytest.MonkeyPatch) -> SimpleNamespace: uri == _legacy_s3_uri(ATTACKER_ORG_ID) or uri == _downloads_s3_uri(ATTACKER_ORG_ID) or uri == _artifact_s3_uri(ATTACKER_ORG_ID) + or uri == _legacy_gcs_uri(ATTACKER_ORG_ID) ): return raise PermissionError(f"No permission to access storage URI: {uri}") @@ -214,3 +219,26 @@ async def test_download_file_reraises_permission_error() -> None: with pytest.raises(PermissionError, match="No permission") as exc_info: await files.download_file(_legacy_s3_uri(VICTIM_ORG_ID), organization_id=ATTACKER_ORG_ID) assert "No permission" in str(exc_info.value) + + +def test_validate_download_url_allows_same_org_gcs_uri() -> None: + # Discriminating check: a gs:// URI must be routed to the managed-storage + # access check, not rejected as an unsupported scheme. + assert files.validate_download_url(_legacy_gcs_uri(ATTACKER_ORG_ID), organization_id=ATTACKER_ORG_ID) is True + + +def test_validate_download_url_rejects_cross_org_gcs_uri() -> None: + assert files.validate_download_url(_legacy_gcs_uri(VICTIM_ORG_ID), organization_id=ATTACKER_ORG_ID) is False + + +@pytest.mark.asyncio +async def test_download_file_routes_gcs_uri_to_managed_storage(storage: SimpleNamespace) -> None: + path = await files.download_file(_legacy_gcs_uri(ATTACKER_ORG_ID), organization_id=ATTACKER_ORG_ID) + + storage.assert_managed_file_access.assert_called_once_with(_legacy_gcs_uri(ATTACKER_ORG_ID), ATTACKER_ORG_ID) + storage.download_managed_file.assert_awaited_once_with(_legacy_gcs_uri(ATTACKER_ORG_ID), ATTACKER_ORG_ID) + try: + with open(path, "rb") as f: + assert f.read() == b"tenant-secret-bytes" + finally: + os.unlink(path) diff --git a/tests/unit/test_local_file_path_validation.py b/tests/unit/test_local_file_path_validation.py index 6b1264f80..1369d22fd 100644 --- a/tests/unit/test_local_file_path_validation.py +++ b/tests/unit/test_local_file_path_validation.py @@ -129,6 +129,9 @@ class TestIsRemoteUrl: def test_s3_uri(self) -> None: assert is_remote_url("s3://bucket/key/file.pdf") is True + def test_gs_uri(self) -> None: + assert is_remote_url("gs://bucket/key/file.pdf") is True + def test_azure_uri(self) -> None: assert is_remote_url("azure://container/blob/file.pdf") is True