Add Google Drive cloud storage uploads (#6884)

Co-authored-by: Suchintan Singh <suchintan@skyvern.com>
This commit is contained in:
Suchintan 2026-06-28 22:39:50 -04:00 committed by GitHub
parent c2802b29d3
commit a293dbfae5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 956 additions and 23 deletions

View file

@ -9999,6 +9999,12 @@
],
"title": "Proxy Session Id",
"description": "Optional advanced reuse key for this credential's pinned proxy identity."
},
"rotate_proxy_session_id": {
"type": "boolean",
"title": "Rotate Proxy Session Id",
"description": "Rotate the Skyvern-managed proxy sticky-session id when updating this credential.",
"default": false
}
},
"type": "object",
@ -12276,7 +12282,8 @@
"type": "string",
"enum": [
"s3",
"azure"
"azure",
"google_drive"
],
"title": "FileStorageType"
},
@ -12433,6 +12440,28 @@
],
"title": "Azure Blob Container Name"
},
"google_credential_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Google Credential Id"
},
"google_drive_folder_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Google Drive Folder Id"
},
"path": {
"anyOf": [
{
@ -12602,6 +12631,28 @@
],
"title": "Azure Folder Path"
},
"google_credential_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Google Credential Id"
},
"google_drive_folder_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Google Drive Folder Id"
},
"path": {
"anyOf": [
{
@ -21581,6 +21632,12 @@
],
"title": "Proxy Session Id",
"description": "Opaque Skyvern-managed proxy sticky-session id."
},
"rotate_proxy_session_id": {
"type": "boolean",
"title": "Rotate Proxy Session Id",
"description": "Rotate the Skyvern-managed proxy sticky-session id for this profile.",
"default": false
}
},
"type": "object",

View file

@ -11517,7 +11517,8 @@
"type": "string",
"enum": [
"s3",
"azure"
"azure",
"google_drive"
],
"title": "FileStorageType"
},
@ -11674,6 +11675,28 @@
],
"title": "Azure Blob Container Name"
},
"google_credential_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Google Credential Id"
},
"google_drive_folder_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Google Drive Folder Id"
},
"path": {
"anyOf": [
{
@ -11837,6 +11860,28 @@
],
"title": "Azure Folder Path"
},
"google_credential_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Google Credential Id"
},
"google_drive_folder_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Google Drive Folder Id"
},
"path": {
"anyOf": [
{

View file

@ -11,7 +11,6 @@ import {
import { Skeleton } from "@/components/ui/skeleton";
import { WorkflowBlockInputTextarea } from "@/components/WorkflowBlockInputTextarea";
import {
GOOGLE_SHEETS_REQUIRED_SCOPES,
getDefaultGoogleOAuthCredentialId,
hasGoogleOAuthCredentialScopes,
useGoogleOAuthCredentials,
@ -22,6 +21,7 @@ type Props = {
nodeId: string;
value: string;
onChange: (value: string) => void;
requiredScopes: readonly string[];
};
const ADVANCED_OPTION = "__advanced__";
@ -31,6 +31,7 @@ function GoogleOAuthCredentialSelector({
nodeId,
value,
onChange,
requiredScopes,
}: Readonly<Props>) {
const {
credentials: allCredentials,
@ -39,7 +40,7 @@ function GoogleOAuthCredentialSelector({
} = useGoogleOAuthCredentials();
const [showAdvanced, setShowAdvanced] = useState(false);
const credentials = allCredentials.filter((credential) =>
hasGoogleOAuthCredentialScopes(credential, GOOGLE_SHEETS_REQUIRED_SCOPES),
hasGoogleOAuthCredentialScopes(credential, requiredScopes),
);
// Keep latest callback without forcing effect re-runs.

View file

@ -4,6 +4,7 @@ import { HelpTooltip } from "@/components/HelpTooltip";
import { WorkflowBlockInput } from "@/components/WorkflowBlockInput";
import { WorkflowBlockInputTextarea } from "@/components/WorkflowBlockInputTextarea";
import { Label } from "@/components/ui/label";
import { GoogleOAuthCredentialSelector } from "@/routes/workflows/components/GoogleOAuthCredentialSelector";
import {
Select,
SelectContent,
@ -11,6 +12,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { GOOGLE_DRIVE_REQUIRED_SCOPES } from "@/util/googleScopes";
import { helpTooltips } from "../../helpContent";
import { type FileUploadNode, type FileUploadNodeData } from "./types";
@ -45,6 +47,8 @@ function FileUploadEditorBody({
azureStorageAccountName,
azureStorageAccountKey,
azureBlobContainerName,
googleCredentialId,
googleDriveFolderId,
} = data;
const update = useUpdate<FileUploadNodeData>({ id: blockId, editable });
@ -58,7 +62,8 @@ function FileUploadEditorBody({
<Select
value={storageType}
onValueChange={(value) =>
value && update({ storageType: value as "s3" | "azure" })
value &&
update({ storageType: value as "s3" | "azure" | "google_drive" })
}
disabled={!editable}
>
@ -68,6 +73,7 @@ function FileUploadEditorBody({
<SelectContent>
<SelectItem value="s3">Amazon S3</SelectItem>
<SelectItem value="azure">Azure Blob Storage</SelectItem>
<SelectItem value="google_drive">Google Drive</SelectItem>
</SelectContent>
</Select>
</div>
@ -223,6 +229,37 @@ function FileUploadEditorBody({
</div>
</>
)}
{storageType === "google_drive" && (
<>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label className="text-sm text-slate-400">Google Account</Label>
<HelpTooltip content="The connected Google account used for Drive uploads." />
</div>
<GoogleOAuthCredentialSelector
nodeId={blockId}
value={googleCredentialId ?? ""}
onChange={(value) => update({ googleCredentialId: value })}
requiredScopes={GOOGLE_DRIVE_REQUIRED_SCOPES}
/>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label className="text-sm text-slate-400">
Google Drive Folder ID (Required)
</Label>
<HelpTooltip content="Required destination Google Drive folder ID. You can paste a Drive folder URL or a bare folder ID." />
</div>
<WorkflowBlockInputTextarea
nodeId={blockId}
onChange={(value) => update({ googleDriveFolderId: value })}
value={googleDriveFolderId ?? ""}
className="nopan text-xs"
/>
</div>
</>
)}
</div>
);
}

View file

@ -5,7 +5,7 @@ import { debuggableWorkflowBlockTypes } from "@/routes/workflows/types/workflowT
export type FileUploadNodeData = NodeBaseData & {
path: string;
editable: boolean;
storageType: "s3" | "azure";
storageType: "s3" | "azure" | "google_drive";
s3Bucket: string | null;
awsAccessKeyId: string | null;
awsSecretAccessKey: string | null;
@ -13,6 +13,8 @@ export type FileUploadNodeData = NodeBaseData & {
azureStorageAccountName: string | null;
azureStorageAccountKey: string | null;
azureBlobContainerName: string | null;
googleCredentialId: string | null;
googleDriveFolderId: string | null;
};
export type FileUploadNode = Node<FileUploadNodeData, "fileUpload">;
@ -30,6 +32,8 @@ export const fileUploadNodeDefaultData: FileUploadNodeData = {
azureStorageAccountName: null,
azureStorageAccountKey: null,
azureBlobContainerName: null,
googleCredentialId: null,
googleDriveFolderId: null,
continueOnFailure: false,
model: null,
} as const;

View file

@ -12,7 +12,10 @@ import {
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { useGoogleOAuthCredentials } from "@/hooks/useGoogleOAuthCredentials";
import {
GOOGLE_SHEETS_REQUIRED_SCOPES,
useGoogleOAuthCredentials,
} from "@/hooks/useGoogleOAuthCredentials";
import { useGoogleSpreadsheet } from "@/hooks/useGoogleSpreadsheet";
import { GoogleOAuthCredentialSelector } from "@/routes/workflows/components/GoogleOAuthCredentialSelector";
import { SheetTabCombobox } from "@/routes/workflows/components/SheetTabCombobox";
@ -102,6 +105,7 @@ function GoogleSheetsReadEditorBody({
nodeId={blockId}
value={data.credentialId}
onChange={(next) => update({ credentialId: next })}
requiredScopes={GOOGLE_SHEETS_REQUIRED_SCOPES}
/>
</div>

View file

@ -12,7 +12,10 @@ import {
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { useGoogleOAuthCredentials } from "@/hooks/useGoogleOAuthCredentials";
import {
GOOGLE_SHEETS_REQUIRED_SCOPES,
useGoogleOAuthCredentials,
} from "@/hooks/useGoogleOAuthCredentials";
import { useGoogleSheetDimensions } from "@/hooks/useGoogleSheetDimensions";
import { useGoogleSheetHeaders } from "@/hooks/useGoogleSheetHeaders";
import { useGoogleSpreadsheet } from "@/hooks/useGoogleSpreadsheet";
@ -144,6 +147,7 @@ function GoogleSheetsWriteEditorBody({
nodeId={blockId}
value={data.credentialId}
onChange={(next) => update({ credentialId: next })}
requiredScopes={GOOGLE_SHEETS_REQUIRED_SCOPES}
/>
{needsReconnect ? (
<a

View file

@ -62,6 +62,19 @@ vi.mock("@/components/WorkflowBlockInputTextarea", () => ({
),
}));
vi.mock("@/routes/workflows/components/GoogleOAuthCredentialSelector", () => ({
GoogleOAuthCredentialSelector: (props: {
value: string;
onChange: (value: string) => void;
}) => (
<input
data-testid="google-oauth-selector"
value={props.value ?? ""}
onChange={(event) => props.onChange(event.target.value)}
/>
),
}));
// Stub the shadcn Select to a native <select> so we can fire change events
// directly. The form only consumes value + onValueChange.
vi.mock("@/components/ui/select", () => {
@ -123,7 +136,7 @@ afterEach(() => {
function setFileUploadNode(
id: string,
overrides: Partial<{
storageType: "s3" | "azure";
storageType: "s3" | "azure" | "google_drive";
path: string;
s3Bucket: string | null;
awsAccessKeyId: string | null;
@ -132,6 +145,8 @@ function setFileUploadNode(
azureStorageAccountName: string | null;
azureStorageAccountKey: string | null;
azureBlobContainerName: string | null;
googleCredentialId: string | null;
googleDriveFolderId: string | null;
editable: boolean;
}> = {},
) {
@ -148,6 +163,8 @@ function setFileUploadNode(
azureStorageAccountName: overrides.azureStorageAccountName ?? null,
azureStorageAccountKey: overrides.azureStorageAccountKey ?? null,
azureBlobContainerName: overrides.azureBlobContainerName ?? null,
googleCredentialId: overrides.googleCredentialId ?? null,
googleDriveFolderId: overrides.googleDriveFolderId ?? null,
editable: overrides.editable ?? true,
label: "file_upload_1",
continueOnFailure: false,
@ -191,6 +208,8 @@ describe("FileUploadBlockForm (SKY-9361)", () => {
expect(screen.queryByText("Storage Account Name")).toBeNull();
expect(screen.queryByText("Storage Account Key")).toBeNull();
expect(screen.queryByText("Blob Container Name")).toBeNull();
expect(screen.queryByText("Google Account")).toBeNull();
expect(screen.queryByText("Google Drive Folder ID")).toBeNull();
// 4 textareas (key id, bucket, region, path) and 1 password input
const textareas = screen.getAllByTestId("wbi-textarea");
@ -220,6 +239,8 @@ describe("FileUploadBlockForm (SKY-9361)", () => {
expect(screen.queryByText("AWS Secret Access Key")).toBeNull();
expect(screen.queryByText("S3 Bucket")).toBeNull();
expect(screen.queryByText("Region Name")).toBeNull();
expect(screen.queryByText("Google Account")).toBeNull();
expect(screen.queryByText("Google Drive Folder ID")).toBeNull();
// 3 textareas (account name, container, path) and 1 password input
const textareas = screen.getAllByTestId("wbi-textarea");
@ -228,6 +249,24 @@ describe("FileUploadBlockForm (SKY-9361)", () => {
expect(password.value).toBe("key");
});
test("renders google drive fields when storageType is 'google_drive'", () => {
setFileUploadNode("f1", {
storageType: "google_drive",
googleCredentialId: "goac_123",
googleDriveFolderId: "folder_123",
});
render(<FileUploadBlockForm blockId="f1" />);
expect(screen.getByText("Google Account")).toBeDefined();
expect(screen.getByText("Google Drive Folder ID (Required)")).toBeDefined();
expect(
(screen.getByTestId("google-oauth-selector") as HTMLInputElement).value,
).toBe("goac_123");
expect(
(screen.getByTestId("wbi-textarea") as HTMLTextAreaElement).value,
).toBe("folder_123");
});
test("switching storageType s3 -> azure swaps the conditional fields", () => {
setFileUploadNode("f1", { storageType: "s3" });
const { rerender } = render(<FileUploadBlockForm blockId="f1" />);
@ -247,11 +286,11 @@ describe("FileUploadBlockForm (SKY-9361)", () => {
render(<FileUploadBlockForm blockId="f1" />);
fireEvent.change(screen.getByTestId("storage-type-select"), {
target: { value: "azure" },
target: { value: "google_drive" },
});
expect(updateNodeData).toHaveBeenCalledWith("f1", {
storageType: "azure",
storageType: "google_drive",
});
});

View file

@ -36,6 +36,8 @@ function FileUploadBlockFormBody({
azureStorageAccountName,
azureStorageAccountKey,
azureBlobContainerName,
googleCredentialId,
googleDriveFolderId,
} = node.data;
const value = useMemo(
@ -49,6 +51,8 @@ function FileUploadBlockFormBody({
azureStorageAccountName,
azureStorageAccountKey,
azureBlobContainerName,
googleCredentialId,
googleDriveFolderId,
}),
[
storageType,
@ -60,6 +64,8 @@ function FileUploadBlockFormBody({
azureStorageAccountName,
azureStorageAccountKey,
azureBlobContainerName,
googleCredentialId,
googleDriveFolderId,
],
);
const { commit } = useDebouncedSidebarSave({

View file

@ -161,6 +161,11 @@ vi.mock("@/routes/workflows/components/SheetTabCombobox", () => ({
}));
vi.mock("@/hooks/useGoogleOAuthCredentials", () => ({
GOOGLE_SHEETS_REQUIRED_SCOPES: [
"https://www.googleapis.com/auth/spreadsheets",
"https://www.googleapis.com/auth/drive.file",
"https://www.googleapis.com/auth/drive.metadata.readonly",
],
useGoogleOAuthCredentials: () => ({
credentials: [{ id: "cred_42", valid: true }],
isLoading: false,

View file

@ -179,6 +179,11 @@ vi.mock("@/routes/workflows/components/ColumnMappingEditor", () => ({
}));
vi.mock("@/hooks/useGoogleOAuthCredentials", () => ({
GOOGLE_SHEETS_REQUIRED_SCOPES: [
"https://www.googleapis.com/auth/spreadsheets",
"https://www.googleapis.com/auth/drive.file",
"https://www.googleapis.com/auth/drive.metadata.readonly",
],
useGoogleOAuthCredentials: () => ({
credentials: [{ id: "cred_42", valid: true }],
isLoading: false,

View file

@ -1061,6 +1061,8 @@ function convertToNode(
azureStorageAccountName: block.azure_storage_account_name ?? "",
azureStorageAccountKey: block.azure_storage_account_key ?? "",
azureBlobContainerName: block.azure_blob_container_name ?? "",
googleCredentialId: block.google_credential_id ?? "",
googleDriveFolderId: block.google_drive_folder_id ?? "",
},
};
}
@ -3034,6 +3036,8 @@ function getWorkflowBlock(
azure_storage_account_name: node.data.azureStorageAccountName ?? "",
azure_storage_account_key: node.data.azureStorageAccountKey ?? "",
azure_blob_container_name: node.data.azureBlobContainerName ?? "",
google_credential_id: node.data.googleCredentialId ?? "",
google_drive_folder_id: node.data.googleDriveFolderId ?? "",
};
}
case "fileParser": {
@ -4367,6 +4371,8 @@ function convertBlocksToBlockYAML(
azure_storage_account_name: block.azure_storage_account_name ?? "",
azure_storage_account_key: block.azure_storage_account_key ?? "",
azure_blob_container_name: block.azure_blob_container_name ?? "",
google_credential_id: block.google_credential_id ?? "",
google_drive_folder_id: block.google_drive_folder_id ?? "",
};
return blockYaml;
}

View file

@ -416,7 +416,7 @@ export type UploadToS3Block = WorkflowBlockBase & {
export type FileUploadBlock = WorkflowBlockBase & {
block_type: "file_upload";
path: string;
storage_type: "s3" | "azure";
storage_type: "s3" | "azure" | "google_drive";
s3_bucket: string | null;
region_name: string | null;
aws_access_key_id: string | null;
@ -424,6 +424,8 @@ export type FileUploadBlock = WorkflowBlockBase & {
azure_storage_account_name: string | null;
azure_storage_account_key: string | null;
azure_blob_container_name: string | null;
google_credential_id: string | null;
google_drive_folder_id: string | null;
};
export type SendEmailBlock = WorkflowBlockBase & {

View file

@ -348,6 +348,8 @@ export type FileUploadBlockYAML = BlockYAMLBase & {
azure_storage_account_name?: string | null;
azure_storage_account_key?: string | null;
azure_blob_container_name?: string | null;
google_credential_id?: string | null;
google_drive_folder_id?: string | null;
};
export type SendEmailBlockYAML = BlockYAMLBase & {

View file

@ -7,3 +7,7 @@ export const GOOGLE_SHEETS_REQUIRED_SCOPES = [
export const GOOGLE_GMAIL_REQUIRED_SCOPES = [
"https://www.googleapis.com/auth/gmail.readonly",
] as const;
export const GOOGLE_DRIVE_REQUIRED_SCOPES = [
"https://www.googleapis.com/auth/drive",
] as const;

View file

@ -2,4 +2,4 @@
import typing
FileStorageType = typing.Union[typing.Literal["s3", "azure"], typing.Any]
FileStorageType = typing.Union[typing.Literal["s3", "azure", "google_drive"], typing.Any]

View file

@ -35,6 +35,8 @@ class FileUploadBlock(UniversalBaseModel):
azure_storage_account_name: typing.Optional[str] = None
azure_storage_account_key: typing.Optional[str] = None
azure_blob_container_name: typing.Optional[str] = None
google_credential_id: typing.Optional[str] = None
google_drive_folder_id: typing.Optional[str] = None
path: typing.Optional[str] = None
if IS_PYDANTIC_V2:

View file

@ -33,6 +33,8 @@ class FileUploadBlockYaml(UniversalBaseModel):
azure_storage_account_key: typing.Optional[str] = None
azure_blob_container_name: typing.Optional[str] = None
azure_folder_path: typing.Optional[str] = None
google_credential_id: typing.Optional[str] = None
google_drive_folder_id: typing.Optional[str] = None
path: typing.Optional[str] = None
if IS_PYDANTIC_V2:

View file

@ -257,6 +257,8 @@ class ForLoopBlockLoopBlocksItem_FileUpload(UniversalBaseModel):
azure_storage_account_name: typing.Optional[str] = None
azure_storage_account_key: typing.Optional[str] = None
azure_blob_container_name: typing.Optional[str] = None
google_credential_id: typing.Optional[str] = None
google_drive_folder_id: typing.Optional[str] = None
path: typing.Optional[str] = None
if IS_PYDANTIC_V2:

View file

@ -208,6 +208,8 @@ class ForLoopBlockYamlLoopBlocksItem_FileUpload(UniversalBaseModel):
azure_storage_account_key: typing.Optional[str] = None
azure_blob_container_name: typing.Optional[str] = None
azure_folder_path: typing.Optional[str] = None
google_credential_id: typing.Optional[str] = None
google_drive_folder_id: typing.Optional[str] = None
path: typing.Optional[str] = None
if IS_PYDANTIC_V2:

View file

@ -257,6 +257,8 @@ class WhileLoopBlockLoopBlocksItem_FileUpload(UniversalBaseModel):
azure_storage_account_name: typing.Optional[str] = None
azure_storage_account_key: typing.Optional[str] = None
azure_blob_container_name: typing.Optional[str] = None
google_credential_id: typing.Optional[str] = None
google_drive_folder_id: typing.Optional[str] = None
path: typing.Optional[str] = None
if IS_PYDANTIC_V2:

View file

@ -208,6 +208,8 @@ class WhileLoopBlockYamlLoopBlocksItem_FileUpload(UniversalBaseModel):
azure_storage_account_key: typing.Optional[str] = None
azure_blob_container_name: typing.Optional[str] = None
azure_folder_path: typing.Optional[str] = None
google_credential_id: typing.Optional[str] = None
google_drive_folder_id: typing.Optional[str] = None
path: typing.Optional[str] = None
if IS_PYDANTIC_V2:

View file

@ -257,6 +257,8 @@ class WorkflowDefinitionBlocksItem_FileUpload(UniversalBaseModel):
azure_storage_account_name: typing.Optional[str] = None
azure_storage_account_key: typing.Optional[str] = None
azure_blob_container_name: typing.Optional[str] = None
google_credential_id: typing.Optional[str] = None
google_drive_folder_id: typing.Optional[str] = None
path: typing.Optional[str] = None
if IS_PYDANTIC_V2:

View file

@ -189,6 +189,8 @@ class WorkflowDefinitionYamlBlocksItem_FileUpload(UniversalBaseModel):
azure_storage_account_key: typing.Optional[str] = None
azure_blob_container_name: typing.Optional[str] = None
azure_folder_path: typing.Optional[str] = None
google_credential_id: typing.Optional[str] = None
google_drive_folder_id: typing.Optional[str] = None
path: typing.Optional[str] = None
if IS_PYDANTIC_V2:

View file

@ -676,6 +676,9 @@ class Settings(BaseSettings):
# Google Sheets API runtime tuning
GOOGLE_SHEETS_API_TIMEOUT_SECONDS: float = 30.0
GOOGLE_SHEETS_API_MAX_RETRIES: int = 3
# Google Drive API runtime tuning
GOOGLE_DRIVE_API_TIMEOUT_SECONDS: float = 30.0
GOOGLE_DRIVE_API_MAX_RETRIES: int = 3
# Cleanup Cron Settings
ENABLE_CLEANUP_CRON: bool = False

View file

@ -2130,6 +2130,8 @@ def _build_file_upload_statement(block: dict[str, Any]) -> cst.SimpleStatementLi
"azure_storage_account_name",
"azure_storage_account_key",
"azure_blob_container_name",
"google_credential_id",
"google_drive_folder_id",
"path",
]:
if block.get(key) is not None:

View file

@ -39,7 +39,7 @@ from skyvern.forge.sdk.db.agent_db import AgentDB
from skyvern.forge.sdk.models import Step, StepStatus
from skyvern.forge.sdk.schemas.organizations import Organization
from skyvern.forge.sdk.schemas.tasks import Task, TaskStatus
from skyvern.forge.sdk.services import google_oauth_service
from skyvern.forge.sdk.services import google_drive_service, google_oauth_service
from skyvern.forge.sdk.trace import traced
from skyvern.forge.sdk.workflow.models.block import BlockTypeVar
from skyvern.schemas.workflows import FileStorageType, FileUploadDestination
@ -1411,7 +1411,7 @@ class AgentFunction:
organization_id: str | None = None,
run_id: str | None = None,
) -> str:
"""Upload a single file to customer-specified S3 or Azure storage.
"""Upload a single file to customer-specified cloud storage.
Returns the customer-facing URI (``destination.customer_uri``). The
cloud override routes NAT-org traffic through the egress proxy so it
@ -1447,6 +1447,16 @@ class AgentFunction:
await azure_client.upload_file_from_path(destination.sdk_uri, file_path)
return destination.customer_uri
if destination.storage_type == FileStorageType.GOOGLE_DRIVE:
if not destination.google_access_token or not destination.google_drive_folder_id:
raise ValueError("Google Drive destination is missing required fields")
uploaded_file = await google_drive_service.upload_file(
access_token=destination.google_access_token,
file_path=file_path,
folder_id=destination.google_drive_folder_id,
)
return uploaded_file.web_view_link or f"https://drive.google.com/file/d/{uploaded_file.id}/view"
raise ValueError(f"Unsupported storage type: {destination.storage_type}")
@staticmethod

View file

@ -608,7 +608,7 @@ Purpose: Upload files to storage. This is the "Cloud Storage Block" in the UI.
Structure:
block_type: file_upload
label: <unique_label>
storage_type: <s3|azure> # Optional: Storage backend (default: s3)
storage_type: <s3|azure|google_drive> # Optional: Storage backend (default: s3)
s3_bucket: <bucket_name> # Optional: S3 bucket name
aws_access_key_id: <access_key_id> # Optional: AWS access key id
aws_secret_access_key: <secret_access_key> # Optional: AWS secret access key
@ -617,11 +617,14 @@ azure_storage_account_name: <account_name> # Optional: Azure storage account
azure_storage_account_key: <account_key> # Optional: Azure storage account key
azure_blob_container_name: <container_name> # Optional: Azure blob container
azure_folder_path: <folder_path> # Optional: Azure folder path
google_credential_id: <credential_id> # Required for google_drive: Google OAuth credential ID
google_drive_folder_id: <folder_id_or_url> # Required for google_drive: Drive folder ID or URL
path: <local_or_workspace_path> # Optional: File path to upload
Use Cases:
- Upload downloaded artifacts to S3
- Publish files to Azure Blob Storage
- Upload workflow files to a Google Drive folder
- Persist workflow outputs to storage
Example:
@ -634,6 +637,16 @@ blocks:
region_name: "us-west-2"
path: "/tmp/latest_report.pdf"
Google Drive example:
blocks:
- block_type: file_upload
label: upload_report_to_drive
next_block_label: null
storage_type: google_drive
google_credential_id: "cred_google_drive"
google_drive_folder_id: "https://drive.google.com/drive/folders/folder_id"
path: "/tmp/latest_report.pdf"
** FILE PARSER BLOCK (file_url_parser) **
Purpose: Parse PDFs, CSVs, Excel files, images, and DOCX files. This is the "File Parser Block" in the UI.

View file

@ -28,10 +28,10 @@ class GoogleOAuthCredentialListResponse(BaseModel):
class CreateGoogleOAuthAuthorizeRequest(BaseModel):
redirect_uri: str = Field(..., description="Redirect URI the consent flow will return to")
credential_name: str = Field(default="Default", description="Human-readable name for this credential")
scope_profile: Literal["google_sheets", "gmail"] | None = Field(
scope_profile: Literal["google_sheets", "gmail", "google_drive"] | None = Field(
default=None,
description="Allowed Google OAuth scope profile to request. Defaults to google_sheets.",
examples=["google_sheets", "gmail"],
examples=["google_sheets", "gmail", "google_drive"],
)
app_origin: str | None = Field(
default=None,

View file

@ -0,0 +1,251 @@
from __future__ import annotations
import asyncio
import json
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from mimetypes import guess_type
from pathlib import Path
from typing import Any
from urllib.parse import urlencode, urlparse
import httpx
from skyvern.config import settings
DRIVE_UPLOAD_API_BASE = "https://www.googleapis.com/upload/drive/v3"
DRIVE_MULTIPART_UPLOAD_MAX_BYTES = 5 * 1024 * 1024
_DEFAULT_BACKOFF_SECONDS = 1.0
class GoogleDriveAPIError(RuntimeError):
def __init__(self, *, status: int, code: str | None, message: str) -> None:
super().__init__(message)
self.status = status
self.code = code
self.message = message
@dataclass(frozen=True)
class UploadedDriveFile:
id: str
web_view_link: str | None = None
@dataclass(frozen=True)
class GoogleDriveMultipartUploadRequest:
target_url: str
headers: dict[str, str]
content: bytes
def _compute_backoff(attempt: int, retry_after: str | None) -> float:
if retry_after:
value = retry_after.strip()
try:
return max(0.0, float(value))
except ValueError:
pass
try:
target = parsedate_to_datetime(value)
except (TypeError, ValueError):
target = None
if target is not None:
if target.tzinfo is None:
target = target.replace(tzinfo=timezone.utc)
return max(0.0, (target - datetime.now(timezone.utc)).total_seconds())
return _DEFAULT_BACKOFF_SECONDS * (2 ** (attempt - 1))
def _raise_for_error(response: httpx.Response) -> None:
if response.is_success:
return
status = response.status_code
try:
payload: Any = response.json() or {}
except ValueError:
raise GoogleDriveAPIError(
status=status,
code=None,
message=response.text[:500] or "Google Drive API error",
) from None
err = payload.get("error") if isinstance(payload, dict) else {}
if not isinstance(err, dict):
raise GoogleDriveAPIError(status=status, code=None, message="Google Drive API error")
message = err.get("message") or "Google Drive API error"
details = err.get("errors")
code: str | None = None
if isinstance(details, list) and details and isinstance(details[0], dict):
code = details[0].get("reason")
if status == 403 and code in {"insufficientPermissions", "insufficientScopes"}:
code = "reconnect_required"
raise GoogleDriveAPIError(status=status, code=code, message=message)
def extract_folder_id(value: str) -> str:
candidate = value.strip()
if not candidate:
raise ValueError("Google Drive folder ID is required")
parsed = urlparse(candidate)
if parsed.scheme and parsed.netloc:
hostname = parsed.hostname or ""
if parsed.scheme != "https" or not (hostname == "google.com" or hostname.endswith(".google.com")):
raise ValueError("Google Drive folder URL must be an https://*.google.com URL")
parts = [part for part in parsed.path.split("/") if part]
for index, part in enumerate(parts):
if part == "folders" and index + 1 < len(parts):
return parts[index + 1]
raise ValueError("Google Drive folder URL must contain /folders/{folder_id}")
return candidate
def _assert_multipart_upload_size(file_path: str, body_size: int | None = None) -> None:
file_size = Path(file_path).stat().st_size
if file_size > DRIVE_MULTIPART_UPLOAD_MAX_BYTES:
raise GoogleDriveAPIError(
status=413,
code="file_too_large",
message=(
"Google Drive multipart uploads are limited to 5 MB. "
"Use a smaller file or wait for resumable Drive upload support."
),
)
if body_size is not None and body_size > DRIVE_MULTIPART_UPLOAD_MAX_BYTES:
raise GoogleDriveAPIError(
status=413,
code="multipart_body_too_large",
message=(
"Google Drive multipart uploads are limited to 5 MB including metadata. "
"Use a smaller file or wait for resumable Drive upload support."
),
)
def _multipart_body(
*,
metadata: dict[str, Any],
file_path: str,
content_type: str,
boundary: str,
) -> bytes:
metadata_bytes = json.dumps(metadata, separators=(",", ":")).encode("utf-8")
return b"".join(
[
f"--{boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n".encode(),
metadata_bytes,
f"\r\n--{boundary}\r\nContent-Type: {content_type}\r\n\r\n".encode(),
Path(file_path).read_bytes(),
f"\r\n--{boundary}--\r\n".encode(),
]
)
def build_multipart_upload_request(
*,
access_token: str,
file_path: str,
folder_id: str,
) -> GoogleDriveMultipartUploadRequest:
"""Build a bounded Google Drive multipart upload request body.
``folder_id`` is expected to be a normalized folder ID. Call
``extract_folder_id`` on user-entered values before invoking this helper.
"""
_assert_multipart_upload_size(file_path)
file_name = Path(file_path).name
content_type = guess_type(file_path)[0] or "application/octet-stream"
metadata = {"name": file_name, "parents": [folder_id]}
boundary = f"skyvern-{uuid.uuid4().hex}"
content = _multipart_body(
metadata=metadata,
file_path=file_path,
content_type=content_type,
boundary=boundary,
)
_assert_multipart_upload_size(file_path, len(content))
query = urlencode({"uploadType": "multipart", "fields": "id,name,webViewLink", "supportsAllDrives": "true"})
return GoogleDriveMultipartUploadRequest(
target_url=f"{DRIVE_UPLOAD_API_BASE}/files?{query}",
headers={
"Authorization": f"Bearer {access_token}",
"Accept": "application/json",
"Content-Type": f"multipart/related; boundary={boundary}",
},
content=content,
)
def uploaded_file_from_payload(payload: Any) -> UploadedDriveFile:
if not isinstance(payload, dict):
raise GoogleDriveAPIError(status=500, code="malformed_response", message="Malformed Drive upload response")
file_id = payload.get("id")
if not file_id:
raise GoogleDriveAPIError(status=500, code="malformed_response", message="Drive response missing file id")
return UploadedDriveFile(
id=file_id,
web_view_link=payload.get("webViewLink"),
)
async def _post_multipart_with_retry(
client: httpx.AsyncClient,
request: GoogleDriveMultipartUploadRequest,
) -> httpx.Response:
"""POST a Drive multipart upload without replaying ambiguous creates.
Google Drive files.create is not idempotent. Retrying after Drive has seen
the POST can create duplicate files, so only retry failures that occur
while acquiring a connection and fail all ambiguous mutation outcomes.
"""
max_attempts = max(1, settings.GOOGLE_DRIVE_API_MAX_RETRIES)
for attempt in range(1, max_attempts + 1):
try:
return await client.post(
request.target_url,
headers=request.headers,
content=request.content,
)
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout) as exc:
if attempt == max_attempts:
raise GoogleDriveAPIError(
status=503,
code="upstream_unavailable",
message=f"Google Drive upload connection failure: {exc}",
) from exc
await asyncio.sleep(_compute_backoff(attempt, None))
continue
except (httpx.TransportError, httpx.TimeoutException) as exc:
raise GoogleDriveAPIError(
status=503,
code="ambiguous_upload_status",
message=(
"Google Drive upload status is unknown after a transport failure. "
"Not retrying automatically to avoid creating duplicate files."
),
) from exc
raise AssertionError("Drive upload retry loop exited without a response")
async def upload_file(
*,
access_token: str,
file_path: str,
folder_id: str,
) -> UploadedDriveFile:
request = build_multipart_upload_request(
access_token=access_token,
file_path=file_path,
folder_id=folder_id,
)
async with httpx.AsyncClient(timeout=settings.GOOGLE_DRIVE_API_TIMEOUT_SECONDS) as client:
response = await _post_multipart_with_retry(client, request)
_raise_for_error(response)
payload = response.json() or {}
return uploaded_file_from_payload(payload)

View file

@ -58,11 +58,17 @@ GOOGLE_SHEETS_SCOPES: tuple[str, ...] = (
)
GOOGLE_GMAIL_READONLY_SCOPE = "https://www.googleapis.com/auth/gmail.readonly"
GOOGLE_GMAIL_SCOPES: tuple[str, ...] = (GOOGLE_GMAIL_READONLY_SCOPE,)
# Drive uploads accept pasted folder IDs/URLs. drive.file cannot write to
# arbitrary existing folders unless the app created or Picker-selected them, so
# this profile intentionally uses full Drive scope until a Picker flow exists.
GOOGLE_DRIVE_SCOPES: tuple[str, ...] = ("https://www.googleapis.com/auth/drive",)
GOOGLE_OAUTH_SCOPE_PROFILE_SHEETS = "google_sheets"
GOOGLE_OAUTH_SCOPE_PROFILE_GMAIL = "gmail"
GOOGLE_OAUTH_SCOPE_PROFILE_DRIVE = "google_drive"
GOOGLE_OAUTH_SCOPE_PROFILES: dict[str, tuple[str, ...]] = {
GOOGLE_OAUTH_SCOPE_PROFILE_SHEETS: GOOGLE_SHEETS_SCOPES,
GOOGLE_OAUTH_SCOPE_PROFILE_GMAIL: GOOGLE_GMAIL_SCOPES,
GOOGLE_OAUTH_SCOPE_PROFILE_DRIVE: GOOGLE_DRIVE_SCOPES,
}
CONSENT_TTL_SECONDS = 600

View file

@ -93,6 +93,7 @@ from skyvern.forge.sdk.models import Step, StepStatus
from skyvern.forge.sdk.schemas.files import FileInfo
from skyvern.forge.sdk.schemas.task_v2 import TaskV2Status
from skyvern.forge.sdk.schemas.tasks import Task, TaskOutput, TaskStatus
from skyvern.forge.sdk.services import google_drive_service, google_oauth_service
from skyvern.forge.sdk.services.bitwarden import BitwardenConstants
from skyvern.forge.sdk.services.credentials import AzureVaultConstants, OnePasswordConstants, generate_totp_code
from skyvern.forge.sdk.settings_manager import SettingsManager
@ -4814,6 +4815,8 @@ class FileUploadBlock(Block):
azure_storage_account_name: str | None = None
azure_storage_account_key: str | None = None
azure_blob_container_name: str | None = None
google_credential_id: str | None = None
google_drive_folder_id: str | None = None
path: str | None = None
continue_on_empty: bool = Field(
default=False,
@ -4852,6 +4855,12 @@ class FileUploadBlock(Block):
if self.azure_blob_container_name and workflow_run_context.has_parameter(self.azure_blob_container_name):
parameters.append(workflow_run_context.get_parameter(self.azure_blob_container_name))
if self.google_credential_id and workflow_run_context.has_parameter(self.google_credential_id):
parameters.append(workflow_run_context.get_parameter(self.google_credential_id))
if self.google_drive_folder_id and workflow_run_context.has_parameter(self.google_drive_folder_id):
parameters.append(workflow_run_context.get_parameter(self.google_drive_folder_id))
return parameters
def format_potential_template_parameters(self, workflow_run_context: WorkflowRunContext) -> None:
@ -4882,6 +4891,14 @@ class FileUploadBlock(Block):
self.azure_blob_container_name = self.format_block_parameter_template_from_workflow_run_context(
self.azure_blob_container_name, workflow_run_context
)
if self.google_credential_id:
self.google_credential_id = self.format_block_parameter_template_from_workflow_run_context(
self.google_credential_id, workflow_run_context
)
if self.google_drive_folder_id:
self.google_drive_folder_id = self.format_block_parameter_template_from_workflow_run_context(
self.google_drive_folder_id, workflow_run_context
)
def _get_s3_uri(self, workflow_run_id: str, path: str) -> str:
folder_path = self.path or f"{workflow_run_id}"
@ -4947,6 +4964,20 @@ class FileUploadBlock(Block):
azure_blob_name=blob_name,
)
@staticmethod
def _build_google_drive_destination(
*,
access_token: str,
folder_id: str,
) -> FileUploadDestination:
return FileUploadDestination(
storage_type=FileStorageType.GOOGLE_DRIVE,
customer_uri=f"https://drive.google.com/drive/folders/{folder_id}",
sdk_uri=f"https://drive.google.com/drive/folders/{folder_id}",
google_access_token=access_token,
google_drive_folder_id=folder_id,
)
@staticmethod
def _candidate_download_signal_run_ids(
*,
@ -5162,6 +5193,11 @@ class FileUploadBlock(Block):
missing_parameters.append("azure_storage_account_key")
if not self.azure_blob_container_name or self.azure_blob_container_name == "":
missing_parameters.append("azure_blob_container_name")
elif self.storage_type == FileStorageType.GOOGLE_DRIVE:
if not self.google_credential_id:
missing_parameters.append("google_credential_id")
if not self.google_drive_folder_id:
missing_parameters.append("google_drive_folder_id")
else:
return await self.build_block_result(
success=False,
@ -5204,7 +5240,7 @@ class FileUploadBlock(Block):
files_to_upload = []
max_file_count = (
MAX_UPLOAD_FILE_COUNT
if self.storage_type == FileStorageType.S3
if self.storage_type in {FileStorageType.S3, FileStorageType.GOOGLE_DRIVE}
else AZURE_BLOB_STORAGE_MAX_UPLOAD_FILE_COUNT
)
files_to_upload = self._get_files_to_upload_from_download_dir(
@ -5337,6 +5373,40 @@ class FileUploadBlock(Block):
)
uploaded_uris.append(customer_uri)
LOG.info("FileUploadBlock File(s) uploaded to Azure Blob Storage", file_path=self.path)
elif self.storage_type == FileStorageType.GOOGLE_DRIVE:
org_id = organization_id or workflow_run_context.organization_id
if not org_id:
raise ValueError("organization_id is required for Google Drive uploads")
google_credential_id = (
workflow_run_context.get_original_secret_value_or_none(self.google_credential_id)
or self.google_credential_id
)
if not google_credential_id:
raise ValueError("Google credential id is required")
google_credentials = await app.AGENT_FUNCTION.get_google_workspace_credentials(
organization_id=org_id,
credential_id=google_credential_id,
required_scopes=list(google_oauth_service.GOOGLE_DRIVE_SCOPES),
)
if not google_credentials or not google_credentials.token:
raise ValueError("Google Drive credential is not connected or is missing required scopes")
folder_id = google_drive_service.extract_folder_id(self.google_drive_folder_id or "")
for file_path in files_to_upload:
LOG.info("FileUploadBlock Uploading file to Google Drive", file_path=file_path)
destination = self._build_google_drive_destination(
access_token=google_credentials.token,
folder_id=folder_id,
)
customer_uri = await app.AGENT_FUNCTION.upload_file_to_customer_storage(
file_path=file_path,
destination=destination,
organization_id=org_id,
run_id=workflow_run_id,
)
uploaded_uris.append(customer_uri)
LOG.info("FileUploadBlock File(s) uploaded to Google Drive", file_path=self.path)
else:
# This case should ideally be caught by the initial validation
raise ValueError(f"Unsupported storage type: {self.storage_type}")

View file

@ -631,6 +631,8 @@ def block_yaml_to_block(
azure_storage_account_name=block_yaml.azure_storage_account_name,
azure_storage_account_key=block_yaml.azure_storage_account_key,
azure_blob_container_name=block_yaml.azure_blob_container_name,
google_credential_id=block_yaml.google_credential_id,
google_drive_folder_id=block_yaml.google_drive_folder_id,
path=block_yaml.path,
)
elif block_yaml.block_type == BlockType.SEND_EMAIL:

View file

@ -503,6 +503,7 @@ class PDFFormat(StrEnum):
class FileStorageType(StrEnum):
S3 = "s3"
AZURE = "azure"
GOOGLE_DRIVE = "google_drive"
class FileUploadDestination(BaseModel):
@ -528,6 +529,9 @@ class FileUploadDestination(BaseModel):
azure_blob_container_name: str | None = None
azure_blob_name: str | None = None
google_access_token: str | None = None
google_drive_folder_id: str | None = None
class ParameterYAML(BaseModel, abc.ABC):
parameter_type: ParameterType
@ -906,6 +910,8 @@ class FileUploadBlockYAML(BlockYAML):
azure_storage_account_key: str | None = None
azure_blob_container_name: str | None = None
azure_folder_path: str | None = None
google_credential_id: str | None = None
google_drive_folder_id: str | None = None
path: str | None = None

View file

@ -2850,6 +2850,8 @@ async def upload_file(
azure_storage_account_name: str | None = None,
azure_storage_account_key: str | None = None,
azure_blob_container_name: str | None = None,
google_credential_id: str | None = None,
google_drive_folder_id: str | None = None,
path: str | None = None,
) -> None:
block_validation_output = await _validate_and_get_output_parameter(label, parameters)
@ -2867,6 +2869,10 @@ async def upload_file(
azure_storage_account_key = _render_template_with_label(azure_storage_account_key, label)
if azure_blob_container_name:
azure_blob_container_name = _render_template_with_label(azure_blob_container_name, label)
if google_credential_id:
google_credential_id = _render_template_with_label(google_credential_id, label)
if google_drive_folder_id:
google_drive_folder_id = _render_template_with_label(google_drive_folder_id, label)
if path:
path = _render_template_with_label(path, label)
file_upload_block = FileUploadBlock(
@ -2881,6 +2887,8 @@ async def upload_file(
azure_storage_account_name=azure_storage_account_name,
azure_storage_account_key=azure_storage_account_key,
azure_blob_container_name=azure_blob_container_name,
google_credential_id=google_credential_id,
google_drive_folder_id=google_drive_folder_id,
path=path,
)
await file_upload_block.execute_safe(

View file

@ -43,6 +43,16 @@ def _azure_destination() -> FileUploadDestination:
)
def _google_drive_destination() -> FileUploadDestination:
return FileUploadDestination(
storage_type=FileStorageType.GOOGLE_DRIVE,
customer_uri="https://drive.google.com/drive/folders/folder_123",
sdk_uri="https://drive.google.com/drive/folders/folder_123",
google_access_token="at-1",
google_drive_folder_id="folder_123",
)
@pytest.fixture
def small_file(tmp_path: Path) -> Path:
fp = tmp_path / "f.bin"
@ -119,6 +129,32 @@ async def test_azure_missing_creds_raises(small_file: Path) -> None:
)
@pytest.mark.asyncio
async def test_google_drive_direct_path_calls_drive_service(small_file: Path) -> None:
destination = _google_drive_destination()
uploaded = MagicMock()
uploaded.id = "file_123"
uploaded.web_view_link = "https://drive.google.com/file/d/file_123/view"
with patch(
"skyvern.forge.agent_functions.google_drive_service.upload_file",
new_callable=AsyncMock,
return_value=uploaded,
) as mock_upload:
result = await AgentFunction().upload_file_to_customer_storage(
file_path=str(small_file),
destination=destination,
organization_id="o_1",
)
assert result == "https://drive.google.com/file/d/file_123/view"
mock_upload.assert_awaited_once_with(
access_token="at-1",
file_path=str(small_file),
folder_id="folder_123",
)
@pytest.mark.asyncio
async def test_size_cap_enforced_on_direct_path(small_file: Path) -> None:
destination = _s3_destination()

View file

@ -1,5 +1,5 @@
from types import SimpleNamespace
from typing import Any
from typing import Any, Awaitable, Callable
from unittest.mock import AsyncMock
from urllib.parse import parse_qs, urlparse
@ -9,8 +9,8 @@ from pydantic import ValidationError
from sqlalchemy.sql.elements import BindParameter
from skyvern.forge.sdk.encrypt.base import EncryptMethod
from skyvern.forge.sdk.schemas.google_oauth import UpdateGoogleOAuthCredentialRequest
from skyvern.forge.sdk.services import google_oauth_service
from skyvern.forge.sdk.schemas.google_oauth import CreateGoogleOAuthAuthorizeRequest, UpdateGoogleOAuthCredentialRequest
from skyvern.forge.sdk.services import google_drive_service, google_oauth_service
def _unwrap_bind(value: Any) -> Any:
@ -22,6 +22,20 @@ def _default_scopes_list() -> list[str]:
return list(google_oauth_service.GOOGLE_SHEETS_SCOPES)
def _install_google_drive_transport(
monkeypatch: pytest.MonkeyPatch,
handler: Callable[[httpx.Request], httpx.Response | Awaitable[httpx.Response]],
) -> None:
transport = httpx.MockTransport(handler)
real_client = httpx.AsyncClient
def fake_async_client(*args: Any, **kwargs: Any) -> httpx.AsyncClient:
kwargs["transport"] = transport
return real_client(*args, **kwargs)
monkeypatch.setattr(google_drive_service.httpx, "AsyncClient", fake_async_client)
def test_coerce_scopes_accepts_strings_and_iterables() -> None:
assert google_oauth_service._coerce_scopes("https://a/scope https://b/scope") == [
"https://a/scope",
@ -43,14 +57,226 @@ def test_google_sheets_scopes_includes_drive_file_and_metadata_readonly() -> Non
assert "https://www.googleapis.com/auth/drive.metadata.readonly" in scopes
def test_google_drive_scope_profile_uses_full_drive_scope_for_folder_uploads() -> None:
scopes = google_oauth_service.scopes_for_profile("google_drive")
assert scopes == [
"https://www.googleapis.com/auth/drive",
]
def test_google_oauth_authorize_request_accepts_google_drive_scope_profile() -> None:
request = CreateGoogleOAuthAuthorizeRequest(
redirect_uri="https://app.example.com/google/callback",
scope_profile="google_drive",
)
assert request.scope_profile == "google_drive"
@pytest.mark.parametrize(
("value", "expected"),
[
("folder_123", "folder_123"),
("https://drive.google.com/drive/u/0/folders/folder_123", "folder_123"),
],
)
def test_google_drive_extract_folder_id(value: str, expected: str) -> None:
assert google_drive_service.extract_folder_id(value) == expected
def test_google_drive_extract_folder_id_rejects_non_folder_url() -> None:
with pytest.raises(ValueError, match="folder URL"):
google_drive_service.extract_folder_id("https://drive.google.com/file/d/file_123/view")
def test_google_drive_extract_folder_id_rejects_non_google_folder_url() -> None:
with pytest.raises(ValueError, match=r"https://\*\.google\.com"):
google_drive_service.extract_folder_id("https://attacker.example.com/folders/folder_123")
@pytest.mark.asyncio
async def test_google_drive_uploads_multipart(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
) -> None:
source = tmp_path / "report.txt"
source.write_text("hello-drive")
captured: dict[str, Any] = {}
async def handler(request: httpx.Request) -> httpx.Response:
url = str(request.url)
captured["url"] = url
captured["auth"] = request.headers.get("Authorization")
captured["content_type"] = request.headers["Content-Type"]
captured["body"] = await request.aread()
return httpx.Response(
200,
json={
"id": "file_123",
"name": "report.txt",
"webViewLink": "https://drive.google.com/file/d/file_123/view",
},
)
_install_google_drive_transport(monkeypatch, handler)
uploaded = await google_drive_service.upload_file(
access_token="at-1",
file_path=str(source),
folder_id="folder_123",
)
assert uploaded.id == "file_123"
assert captured["auth"] == "Bearer at-1"
assert "/upload/drive/v3/files" in captured["url"]
assert "uploadType=multipart" in captured["url"]
assert "supportsAllDrives=true" in captured["url"]
assert str(captured["content_type"]).startswith("multipart/related; boundary=skyvern-")
body = captured["body"]
assert isinstance(body, bytes)
assert b'"parents":["folder_123"]' in body
assert b'"name":"report.txt"' in body
@pytest.mark.asyncio
async def test_google_drive_upload_does_not_retry_retryable_create_response(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
) -> None:
source = tmp_path / "report.txt"
source.write_text("hello-drive")
calls = 0
async def handler(_request: httpx.Request) -> httpx.Response:
nonlocal calls
calls += 1
if calls > 1:
return httpx.Response(
200, json={"id": "file_123", "webViewLink": "https://drive.google.com/file/d/123/view"}
)
return httpx.Response(503, headers={"Retry-After": "0"}, json={"error": {"message": "try later"}})
_install_google_drive_transport(monkeypatch, handler)
sleep_mock = AsyncMock()
monkeypatch.setattr(google_drive_service.asyncio, "sleep", sleep_mock)
with pytest.raises(google_drive_service.GoogleDriveAPIError) as exc_info:
await google_drive_service.upload_file(
access_token="at-1",
file_path=str(source),
folder_id="folder_123",
)
assert exc_info.value.status == 503
assert calls == 1
sleep_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_google_drive_upload_retries_connection_failures_before_request(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
) -> None:
source = tmp_path / "report.txt"
source.write_text("hello-drive")
calls = 0
async def handler(request: httpx.Request) -> httpx.Response:
nonlocal calls
calls += 1
if calls == 1:
raise httpx.ConnectError("connect failed", request=request)
return httpx.Response(200, json={"id": "file_123", "webViewLink": "https://drive.google.com/file/d/123/view"})
_install_google_drive_transport(monkeypatch, handler)
sleep_mock = AsyncMock()
monkeypatch.setattr(google_drive_service.asyncio, "sleep", sleep_mock)
uploaded = await google_drive_service.upload_file(
access_token="at-1",
file_path=str(source),
folder_id="folder_123",
)
assert uploaded.id == "file_123"
assert calls == 2
sleep_mock.assert_awaited_once_with(1.0)
@pytest.mark.asyncio
async def test_google_drive_upload_does_not_retry_ambiguous_transport_failure(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
) -> None:
source = tmp_path / "report.txt"
source.write_text("hello-drive")
calls = 0
async def handler(request: httpx.Request) -> httpx.Response:
nonlocal calls
calls += 1
raise httpx.ReadTimeout("read timed out", request=request)
_install_google_drive_transport(monkeypatch, handler)
sleep_mock = AsyncMock()
monkeypatch.setattr(google_drive_service.asyncio, "sleep", sleep_mock)
with pytest.raises(google_drive_service.GoogleDriveAPIError) as exc_info:
await google_drive_service.upload_file(
access_token="at-1",
file_path=str(source),
folder_id="folder_123",
)
assert exc_info.value.status == 503
assert exc_info.value.code == "ambiguous_upload_status"
assert calls == 1
sleep_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_google_drive_upload_rejects_multipart_files_over_google_limit(tmp_path) -> None:
source = tmp_path / "large.bin"
source.write_bytes(b"x" * (google_drive_service.DRIVE_MULTIPART_UPLOAD_MAX_BYTES + 1))
with pytest.raises(google_drive_service.GoogleDriveAPIError) as exc_info:
await google_drive_service.upload_file(
access_token="at-1",
file_path=str(source),
folder_id="folder_123",
)
assert exc_info.value.status == 413
assert exc_info.value.code == "file_too_large"
def test_google_drive_maps_insufficient_scope_to_reconnect() -> None:
response = httpx.Response(
403,
json={
"error": {
"message": "Request had insufficient authentication scopes.",
"errors": [{"reason": "insufficientPermissions"}],
}
},
)
with pytest.raises(google_drive_service.GoogleDriveAPIError) as exc_info:
google_drive_service._raise_for_error(response)
assert exc_info.value.status == 403
assert exc_info.value.code == "reconnect_required"
def test_sheets_api_runtime_defaults_match_previous_hardcoded_values() -> None:
"""Sheets timeout/retry settings default to known values so unset envs
"""Google API timeout/retry settings default to known values so unset envs
produce no behavior change for upgrading deployments."""
from skyvern.config import Settings
fresh = Settings()
assert fresh.GOOGLE_SHEETS_API_TIMEOUT_SECONDS == 30.0
assert fresh.GOOGLE_SHEETS_API_MAX_RETRIES == 3
assert fresh.GOOGLE_DRIVE_API_TIMEOUT_SECONDS == 30.0
assert fresh.GOOGLE_DRIVE_API_MAX_RETRIES == 3
def test_build_authorize_url_includes_required_params(monkeypatch: pytest.MonkeyPatch) -> None:

View file

@ -967,6 +967,69 @@ async def test_file_upload_block_continue_on_empty_succeeds(tmp_path) -> None:
mock_app.AGENT_FUNCTION.upload_file_to_customer_storage.assert_not_awaited()
@pytest.mark.asyncio
async def test_file_upload_block_uploads_downloads_to_google_drive(tmp_path) -> None:
from types import SimpleNamespace
from skyvern.forge.sdk.workflow.models.block import FileUploadBlock
from skyvern.schemas.workflows import FileStorageType
download_dir = tmp_path / "wr_drive"
download_dir.mkdir()
source = download_dir / "report.txt"
source.write_text("drive upload")
block = FileUploadBlock.model_construct(
label="upload",
storage_type=FileStorageType.GOOGLE_DRIVE,
google_credential_id="goac_123",
google_drive_folder_id="https://drive.google.com/drive/folders/folder_123",
path=None,
continue_on_empty=False,
)
sentinel = object()
workflow_run_context = MagicMock()
workflow_run_context.organization_id = "org_1"
workflow_run_context.get_original_secret_value_or_none.return_value = None
with (
patch.object(FileUploadBlock, "get_workflow_run_context", return_value=workflow_run_context),
patch.object(FileUploadBlock, "format_potential_template_parameters", return_value=None),
patch.object(FileUploadBlock, "record_output_parameter_value", new_callable=AsyncMock),
patch.object(
FileUploadBlock, "build_block_result", new_callable=AsyncMock, return_value=sentinel
) as mock_result,
patch(
"skyvern.forge.sdk.workflow.models.block.get_path_for_workflow_download_directory",
return_value=download_dir,
),
patch("skyvern.forge.sdk.workflow.models.block.skyvern_context.current", return_value=None),
patch("skyvern.forge.sdk.workflow.models.block.app") as mock_app,
):
mock_app.AGENT_FUNCTION.get_google_workspace_credentials = AsyncMock(return_value=SimpleNamespace(token="at-1"))
mock_app.AGENT_FUNCTION.upload_file_to_customer_storage = AsyncMock(
return_value="https://drive.google.com/file/d/file_123/view"
)
result = await block.execute(
workflow_run_id="wr_drive",
workflow_run_block_id="wrb_x",
organization_id="org_1",
)
assert result is sentinel
assert mock_result.await_args.kwargs["success"] is True
assert mock_result.await_args.kwargs["output_parameter_value"] == ["https://drive.google.com/file/d/file_123/view"]
mock_app.AGENT_FUNCTION.upload_file_to_customer_storage.assert_awaited_once()
upload_kwargs = mock_app.AGENT_FUNCTION.upload_file_to_customer_storage.await_args.kwargs
assert upload_kwargs["file_path"] == str(source)
assert upload_kwargs["organization_id"] == "org_1"
assert upload_kwargs["run_id"] == "wr_drive"
destination = upload_kwargs["destination"]
assert destination.storage_type == FileStorageType.GOOGLE_DRIVE
assert destination.google_access_token == "at-1"
assert destination.google_drive_folder_id == "folder_123"
def test_resolve_run_download_id_preserves_task_id_tail() -> None:
"""CORR-1: mirrors handler.py fallback_run_id=task.workflow_run_id or task.task_id — when both
context and workflow_run_id are absent, the task_id tail must still be resolved (not None)."""