From a6203ef52bbe248a9bcbe1d9a1dc480802767595 Mon Sep 17 00:00:00 2001 From: Shuchang Zheng Date: Sat, 25 Apr 2026 15:00:27 -0700 Subject: [PATCH] fix(sky-8861): UI shows real filename for short artifact URLs (#5666) --- .../src/routes/workflows/WorkflowRun.tsx | 5 +- .../workflowRun/WorkflowRunOutput.tsx | 9 ++-- .../workflowRun/blockDownloadedFiles.test.ts | 47 ++++++++++++++++++- .../workflowRun/blockDownloadedFiles.ts | 40 +++++++++++++++- 4 files changed, 92 insertions(+), 9 deletions(-) diff --git a/skyvern-frontend/src/routes/workflows/WorkflowRun.tsx b/skyvern-frontend/src/routes/workflows/WorkflowRun.tsx index 90ff37f82..453426a7a 100644 --- a/skyvern-frontend/src/routes/workflows/WorkflowRun.tsx +++ b/skyvern-frontend/src/routes/workflows/WorkflowRun.tsx @@ -40,6 +40,7 @@ import { useWorkflowRunWithWorkflowQuery } from "./hooks/useWorkflowRunWithWorkf import { WorkflowRunTimeline } from "./workflowRun/WorkflowRunTimeline"; import { useWorkflowRunTimelineQuery } from "./hooks/useWorkflowRunTimelineQuery"; import { findActiveItem } from "./workflowRun/workflowTimelineUtils"; +import { filenameForDownloadedFileUrl } from "./workflowRun/blockDownloadedFiles"; import { isBlockItem } from "./types/workflowRunTypes"; import { Label } from "@/components/ui/label"; import { CodeEditor } from "./components/CodeEditor"; @@ -515,9 +516,7 @@ function WorkflowRun() { {fileUrls.length > 0 ? ( fileUrls.map((url) => { - // Extract filename from URL path, stripping query params from signed URLs - const urlPath = url.split("?")[0] ?? url; - const filename = urlPath.split("/").pop() || "download"; + const filename = filenameForDownloadedFileUrl(url); return (
diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunOutput.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunOutput.tsx index 6894b235f..460f34fdd 100644 --- a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunOutput.tsx +++ b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunOutput.tsx @@ -17,7 +17,10 @@ import { AutoResizingTextarea } from "@/components/AutoResizingTextarea/AutoResi import { SummarizeOutput } from "@/components/SummarizeOutput"; import { isTaskVariantBlock } from "../types/workflowTypes"; import { statusIsAFailureType } from "@/routes/tasks/types"; -import { getBlockDownloadedFileUrls } from "./blockDownloadedFiles"; +import { + filenameForDownloadedFileUrl, + getBlockDownloadedFileUrls, +} from "./blockDownloadedFiles"; function SummaryDisplay({ summary, @@ -344,9 +347,7 @@ function WorkflowRunOutput() {
{fileUrls.length > 0 ? ( fileUrls.map((url) => { - // Extract filename from URL path, stripping query params from signed URLs - const urlPath = url.split("?")[0] ?? url; - const filename = urlPath.split("/").pop() || "download"; + const filename = filenameForDownloadedFileUrl(url); return (
diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/blockDownloadedFiles.test.ts b/skyvern-frontend/src/routes/workflows/workflowRun/blockDownloadedFiles.test.ts index cff36a6f0..6514aa37b 100644 --- a/skyvern-frontend/src/routes/workflows/workflowRun/blockDownloadedFiles.test.ts +++ b/skyvern-frontend/src/routes/workflows/workflowRun/blockDownloadedFiles.test.ts @@ -1,5 +1,50 @@ import { describe, expect, test } from "vitest"; -import { getBlockDownloadedFileUrls } from "./blockDownloadedFiles"; +import { + filenameForDownloadedFileUrl, + getBlockDownloadedFileUrls, +} from "./blockDownloadedFiles"; + +describe("filenameForDownloadedFileUrl", () => { + test("short signed URL: pulls filename from artifact_name query param", () => { + const url = + "https://api.skyvern.com/v1/artifacts/a_123/content" + + "?expiry=1&kid=k&sig=s&artifact_name=invoice-2026.pdf&artifact_type=download"; + expect(filenameForDownloadedFileUrl(url)).toBe("invoice-2026.pdf"); + }); + + test("short signed URL with non-ASCII filename: round-trips via URL decoding", () => { + const url = + "https://api.skyvern.com/v1/artifacts/a_123/content" + + "?expiry=1&kid=k&sig=s&artifact_name=" + + encodeURIComponent("\u62a5\u544a-2026.pdf"); + expect(filenameForDownloadedFileUrl(url)).toBe("报告-2026.pdf"); + }); + + test("legacy S3 presigned URL: pulls filename from path basename", () => { + const url = + "https://skyvern-uploads.s3.amazonaws.com/" + + "downloads/production/o_1/wr_1/legacy-report.pdf" + + "?AWSAccessKeyId=ASIA&Signature=sig&Expires=1234567890"; + expect(filenameForDownloadedFileUrl(url)).toBe("legacy-report.pdf"); + }); + + test("legacy S3 presigned URL with percent-encoded filename: decodes the basename", () => { + const url = + "https://skyvern-uploads.s3.amazonaws.com/" + + "downloads/production/o_1/wr_1/Q2%20report.pdf" + + "?AWSAccessKeyId=x&Signature=y"; + expect(filenameForDownloadedFileUrl(url)).toBe("Q2 report.pdf"); + }); + + test("short URL without artifact_name and trailing /content: returns 'download'", () => { + const url = "https://api.skyvern.com/v1/artifacts/a_123/content?sig=x"; + expect(filenameForDownloadedFileUrl(url)).toBe("download"); + }); + + test("malformed URL: returns 'download'", () => { + expect(filenameForDownloadedFileUrl("not a url")).toBe("download"); + }); +}); describe("getBlockDownloadedFileUrls", () => { test("returns [] when blockOutput is null/undefined/string/array", () => { diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/blockDownloadedFiles.ts b/skyvern-frontend/src/routes/workflows/workflowRun/blockDownloadedFiles.ts index 5e4eebf34..57fa476da 100644 --- a/skyvern-frontend/src/routes/workflows/workflowRun/blockDownloadedFiles.ts +++ b/skyvern-frontend/src/routes/workflows/workflowRun/blockDownloadedFiles.ts @@ -8,6 +8,44 @@ function urlKey(url: string): string { return url.split("?")[0] ?? url; } +/** + * Best-effort filename for a downloaded-file URL. + * + * Two URL shapes are in the wild: + * 1. Short signed artifact URLs introduced in SKY-8861, of shape + * ``/v1/artifacts/{id}/content?artifact_name=foo.pdf&...``. The path + * basename is always ``content``; the real filename is in the query + * parameter. + * 2. Legacy S3 presigned URLs, of shape + * ``https://skyvern-uploads.s3.amazonaws.com/downloads/.../foo.pdf?...``. + * The path basename *is* the filename, but it may be percent-encoded + * for filenames containing spaces / unicode. + */ +function filenameForDownloadedFileUrl(url: string): string { + const fallback = "download"; + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return fallback; + } + // searchParams.get auto-decodes percent-encoding, so non-ASCII + // artifact_name values round-trip without an explicit decodeURIComponent. + const fromQuery = parsed.searchParams.get("artifact_name"); + if (fromQuery) { + return fromQuery; + } + const last = parsed.pathname.split("/").pop(); + if (!last || last === "content") { + return fallback; + } + try { + return decodeURIComponent(last); + } catch { + return last; + } +} + function getBlockDownloadedFileUrls( blockOutput: object | Array | string | null | undefined, freshFallbackUrls: ReadonlyArray, @@ -41,4 +79,4 @@ function getBlockDownloadedFileUrls( return blockUrls.map((url) => freshByPath.get(urlKey(url)) ?? url); } -export { getBlockDownloadedFileUrls }; +export { filenameForDownloadedFileUrl, getBlockDownloadedFileUrls };