molecule-core/canvas/src/components/tabs/chat/uploads.ts
Hongming Wang 5d8b5e96e3 fix(canvas/chat): handle platform-pending: scheme for poll-mode upload downloads
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:<wsid>/<file_id>`
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/<wsid>/pending-uploads/
  <file_id>/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) <noreply@anthropic.com>
2026-05-05 16:55:43 -07:00

174 lines
7.1 KiB
TypeScript

import { PLATFORM_URL } from "@/lib/api";
import { getTenantSlug } from "@/lib/tenant";
import type { ChatAttachment } from "./types";
/** Chat attachments are intentionally uploaded via a direct fetch()
* instead of the `api.post` helper — `api.post` JSON-stringifies the
* body, which would 500 on a Blob. Mirrors the header plumbing
* (tenant slug, admin token, credentials) so SaaS + self-hosted
* callers work the same way. */
export async function uploadChatFiles(
workspaceId: string,
files: File[],
): Promise<ChatAttachment[]> {
if (files.length === 0) return [];
const form = new FormData();
for (const f of files) form.append("files", f, f.name);
const headers: Record<string, string> = {};
const slug = getTenantSlug();
if (slug) headers["X-Molecule-Org-Slug"] = slug;
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
// Uploads legitimately take a while on cold cache (tar write +
// docker cp into the container). 60s is comfortable for the 25MB/
// 50MB caps the server enforces.
const res = await fetch(`${PLATFORM_URL}/workspaces/${workspaceId}/chat/uploads`, {
method: "POST",
headers,
body: form,
credentials: "include",
signal: AbortSignal.timeout(60_000),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`upload failed: ${res.status} ${text}`);
}
const json = (await res.json()) as { files: ChatAttachment[] };
return json.files ?? [];
}
/** Resolve a file URI into a browser-downloadable URL. Accepts:
* - `workspace:<abs-path>` (our canonical form)
* - `file:///workspace/...` (some agents emit this)
* - `/workspace/...` (bare absolute path inside the container)
* - `platform-pending:<wsid>/<file_id>` (poll-mode upload, staged
* on platform side; resolves to /pending-uploads/<file_id>/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
* artefacts hosted off-platform. Unknown schemes fall back to the
* raw URI — the caller gets to decide how to render it. */
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:<workspace_id>/<file_id>`. Resolving it
// requires hitting GET /workspaces/<wsid>/pending-uploads/<file_id>/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)}`;
}
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. */
const ALLOWED_CONTAINER_ROOTS = ["/configs", "/workspace", "/home", "/plugins"];
function normalizeWorkspaceUri(uri: string): string | null {
let path: string | null = null;
if (uri.startsWith("workspace:")) {
path = uri.slice("workspace:".length);
} else if (uri.startsWith("file:///")) {
path = uri.slice("file://".length); // keep the leading slash
} else if (uri.startsWith("/")) {
path = uri;
}
if (!path) return null;
// Only rewrite when the path lands in an allowed root; otherwise
// return null so the caller falls through to raw-URI handling
// (which will open a new tab for HTTP-ish schemes).
for (const root of ALLOWED_CONTAINER_ROOTS) {
if (path === root || path.startsWith(root + "/")) return path;
}
return null;
}
/** Trigger a browser download for an attachment. Uses fetch+blob
* rather than an anchor navigation because the download endpoint
* requires workspace auth — and the browser won't attach
* `Authorization: Bearer` or `X-Molecule-Org-Slug` to a bare anchor
* click. A 25MB per-file cap server-side keeps the blob buffer
* bounded. HTTP(S) URIs skip the fetch path and open directly
* since they're off-platform artefacts that we don't own auth for. */
export async function downloadChatFile(
workspaceId: string,
attachment: ChatAttachment,
): Promise<void> {
const href = resolveAttachmentHref(workspaceId, attachment.uri);
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).
window.open(href, "_blank", "noopener,noreferrer");
return;
}
const headers: Record<string, string> = {};
const slug = getTenantSlug();
if (slug) headers["X-Molecule-Org-Slug"] = slug;
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
const res = await fetch(href, {
headers,
credentials: "include",
signal: AbortSignal.timeout(60_000),
});
if (!res.ok) {
throw new Error(`download failed: ${res.status}`);
}
const blob = await res.blob();
// Revoke the object URL after the click — browsers hold the blob
// until the URL is either revoked or the document unloads. 30s is
// plenty of headroom for the click → save dialog round-trip.
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = attachment.name;
a.rel = "noopener";
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 30_000);
}