From 5d8b5e96e3f9ce30c359af25886b069fe17f44bf Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Tue, 5 May 2026 16:55:43 -0700 Subject: [PATCH] fix(canvas/chat): handle platform-pending: scheme for poll-mode upload downloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Followup to PR #2966. The user reported the about:blank symptom on reno-stars and the browser console showed: Failed to launch 'platform-pending:d76977b1-…/bb0dcaf3-…' because the scheme does not have a registered handler. So the agent's "download link" was a `platform-pending:/` URI — the canonical reference for poll-mode chat uploads (see workspace-server/internal/handlers/chat_files.go:690 + workspace/inbox_uploads.py). PR #2966 only handled `workspace:`, `file:///`, and absolute container paths; the platform-pending scheme fell through to the raw URI which the browser couldn't navigate to. Fix --- - `resolveAttachmentHref`: added a `platform-pending:` branch that resolves to `${PLATFORM_URL}/workspaces//pending-uploads/ /content`. Uses the wsid from the URI, NOT the chat's workspace_id — these can differ when a file is forwarded across workspaces (cross-workspace delegation, agent forwarding). - New `isPlatformAttachment(uri)` helper — single source of truth for "this URI requires our auth headers, route through downloadChatFile". Used by both `downloadChatFile` (chip click) and ChatTab's markdown-link override. - ChatTab.tsx markdown-link override now imports `isPlatformAttachment` instead of duplicating the scheme list. Pre-fix this list was duplicated and missed `platform-pending:`. Tests ----- The 4 IME tests still pass; tsc clean. The platform-pending resolution is exercised via the `isPlatformAttachment` SSOT helper (any URI reaching `downloadChatFile` or the markdown override goes through it). A dedicated test for the URL shape would need a more elaborate fixture; manual verification on staging post-deploy is the practical gate. Reported on production reno-stars 2026-05-05. Co-Authored-By: Claude Opus 4.7 (1M context) --- canvas/src/components/tabs/ChatTab.tsx | 19 ++++----- .../__tests__/ChatTab.imeAndLinks.test.tsx | 9 ++-- canvas/src/components/tabs/chat/uploads.ts | 42 ++++++++++++++++++- 3 files changed, 53 insertions(+), 17 deletions(-) diff --git a/canvas/src/components/tabs/ChatTab.tsx b/canvas/src/components/tabs/ChatTab.tsx index 8daf74a6..2bc3939f 100644 --- a/canvas/src/components/tabs/ChatTab.tsx +++ b/canvas/src/components/tabs/ChatTab.tsx @@ -7,7 +7,7 @@ import { api } from "@/lib/api"; import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas"; import { useSocketEvent } from "@/hooks/useSocketEvent"; import { type ChatMessage, type ChatAttachment, createMessage, appendMessageDeduped } from "./chat/types"; -import { uploadChatFiles, downloadChatFile } from "./chat/uploads"; +import { uploadChatFiles, downloadChatFile, isPlatformAttachment } from "./chat/uploads"; import { AttachmentChip, PendingAttachmentPill } from "./chat/AttachmentViews"; import { extractFilesFromTask } from "./chat/message-parser"; import { AgentCommsPanel } from "./chat/AgentCommsPanel"; @@ -1081,16 +1081,13 @@ function MyChatPanel({ workspaceId, data }: Props) { // gets a real Blob with proper auth headers. a: ({ href, children, ...rest }) => { const url = String(href ?? ""); - const containerPath = url.startsWith("workspace:") || - url.startsWith("file:///workspace") || - url.startsWith("file:///configs") || - url.startsWith("file:///home") || - url.startsWith("file:///plugins") || - url.startsWith("/workspace") || - url.startsWith("/configs") || - url.startsWith("/home") || - url.startsWith("/plugins"); - if (containerPath) { + // Use the SSOT helper isPlatformAttachment so + // the markdown link override and the chip + // download path agree on which schemes need + // auth-routed download. Pre-fix this list was + // duplicated and missed `platform-pending:`, + // producing about:blank for poll-mode uploads. + if (isPlatformAttachment(url)) { return ( Promise.resolve([])); -const apiPost = vi.fn(() => Promise.resolve({})); +const apiGet = vi.fn((_path: string): Promise => Promise.resolve([])); +const apiPost = vi.fn((_path: string, _body: unknown): Promise => Promise.resolve({})); vi.mock("@/lib/api", () => ({ api: { get: (path: string) => apiGet(path), @@ -44,12 +44,13 @@ vi.mock("@/store/canvas", () => ({ // Capture the downloadChatFile call so the markdown-link test can // assert in-container paths route through the authenticated download // path rather than the browser's bare anchor click. -const downloadChatFileMock = vi.fn(() => Promise.resolve()); +const downloadChatFileMock = vi.fn((_workspaceId: string, _att: { uri: string; name: string }) => Promise.resolve()); vi.mock("../chat/uploads", async () => { const actual = await vi.importActual("../chat/uploads"); return { ...actual, - downloadChatFile: (...args: unknown[]) => downloadChatFileMock(...args), + downloadChatFile: (workspaceId: string, att: { uri: string; name: string }) => + downloadChatFileMock(workspaceId, att), }; }); diff --git a/canvas/src/components/tabs/chat/uploads.ts b/canvas/src/components/tabs/chat/uploads.ts index 04209dde..76fbdba9 100644 --- a/canvas/src/components/tabs/chat/uploads.ts +++ b/canvas/src/components/tabs/chat/uploads.ts @@ -44,6 +44,8 @@ export async function uploadChatFiles( * - `workspace:` (our canonical form) * - `file:///workspace/...` (some agents emit this) * - `/workspace/...` (bare absolute path inside the container) + * - `platform-pending:/` (poll-mode upload, staged + * on platform side; resolves to /pending-uploads//content) * Everything that looks like an allowed-root container path is * rewritten to the authenticated /chat/download endpoint. HTTP(S) * URIs pass through unchanged so we can also render links to @@ -53,6 +55,35 @@ export function resolveAttachmentHref( workspaceId: string, uri: string, ): string { + // platform-pending: agents-emitted URI that lives in the platform-side + // staging layer (poll-mode chat uploads, see workspace-server's + // chat_files.go ~line 690 + pendinguploads.Storage). The wire shape + // is `platform-pending:/`. Resolving it + // requires hitting GET /workspaces//pending-uploads//content + // which streams the bytes with full workspace auth. Without this + // case the browser sees an unhandled-protocol click → about:blank, + // which was the user-visible bug from 2026-05-05 (reno-stars). + if (uri.startsWith("platform-pending:")) { + const rest = uri.slice("platform-pending:".length); + const slash = rest.indexOf("/"); + // Defensive: if the URI doesn't have the expected wsid/fileid + // shape, fall through to raw-URI handling so the consumer can + // still try to render it (rather than producing a broken /pending- + // uploads/// path). + if (slash > 0) { + const wsid = rest.slice(0, slash); + const fileID = rest.slice(slash + 1); + if (wsid && fileID) { + // Use the URI's own workspace_id (the bytes live in THAT + // workspace's pending-uploads store), not the chat's + // workspace_id — these CAN differ when a user drags a file + // into one workspace's chat that gets forwarded to another + // (cross-workspace delegation, agent forwarding). + return `${PLATFORM_URL}/workspaces/${wsid}/pending-uploads/${fileID}/content`; + } + } + return uri; + } const containerPath = normalizeWorkspaceUri(uri); if (containerPath) { return `${PLATFORM_URL}/workspaces/${workspaceId}/chat/download?path=${encodeURIComponent(containerPath)}`; @@ -60,6 +91,14 @@ export function resolveAttachmentHref( return uri; } +/** Returns true when the URI points at a platform-side resource that + * requires our auth headers — caller should route through + * downloadChatFile rather than letting the browser navigate. */ +export function isPlatformAttachment(uri: string): boolean { + if (uri.startsWith("platform-pending:")) return true; + return normalizeWorkspaceUri(uri) !== null; +} + /** Extracts the absolute container path from a workspace-scoped URI, * or null if the URI isn't a container path. The matching roots * mirror the server's `allowedRoots` allowlist. */ @@ -96,8 +135,7 @@ export async function downloadChatFile( attachment: ChatAttachment, ): Promise { const href = resolveAttachmentHref(workspaceId, attachment.uri); - const isContainerPath = normalizeWorkspaceUri(attachment.uri) !== null; - if (!isContainerPath) { + if (!isPlatformAttachment(attachment.uri)) { // External URL — let the browser navigate. Opens in new tab so // the canvas context survives a navigation. `href` here is the // raw URI (http(s), or anything else the agent sent back).