Compare commits

...

4 Commits

Author SHA1 Message Date
rohoswagger
ff3b82d15a fix(craft): resilient file listing and stale session workspace detection
- list_directory now returns empty listing instead of 404 when the
  directory doesn't exist (e.g., session workspace not yet loaded)
- get_or_create_empty_session now checks session_workspace_exists
  before reusing a cached session, preventing stale sessions with
  missing workspaces from being returned

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:18:07 -08:00
rohoswagger
fa65567d86 fix(craft): filter cc_pair by creator_id in get_or_create_craft_connector
Admin users see all cc_pairs (bypassing user filters), so the first
CRAFT_FILE cc_pair found could belong to a different user. Documents
linked to the wrong cc_pair become invisible in /tree which filters
by creator_id. Now reuses the shared connector but creates per-user
cc_pairs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:16:45 -08:00
rohoswagger
91674ae85c fix(craft): use slicing instead of replace for arcname in download_directory
replace() is fragile — slicing the prefix is explicit and correct by
construction regardless of path contents.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 09:16:30 -08:00
rohoswagger
26d7461335 feat(craft): make output files downloadable from Artifacts tab
Show top-level items from outputs/ in the Artifacts tab with download
buttons. Files download directly; folders are zipped server-side first.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:30:16 -08:00
5 changed files with 299 additions and 40 deletions

View File

@@ -762,6 +762,43 @@ def download_webapp(
)
@router.get("/{session_id}/download-directory/{path:path}")
def download_directory(
session_id: UUID,
path: str,
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> Response:
"""
Download a directory as a zip file.
Returns the specified directory as a zip archive.
"""
user_id: UUID = user.id
session_manager = SessionManager(db_session)
try:
result = session_manager.download_directory(session_id, user_id, path)
except ValueError as e:
error_message = str(e)
if "path traversal" in error_message.lower():
raise HTTPException(status_code=403, detail="Access denied")
raise HTTPException(status_code=400, detail=error_message)
if result is None:
raise HTTPException(status_code=404, detail="Directory not found")
zip_bytes, filename = result
return Response(
content=zip_bytes,
media_type="application/zip",
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
},
)
@router.post("/{session_id}/upload", response_model=UploadResponse)
def upload_file_endpoint(
session_id: UUID,

View File

@@ -107,27 +107,23 @@ def get_or_create_craft_connector(db_session: Session, user: User) -> tuple[int,
)
for cc_pair in cc_pairs:
if cc_pair.connector.source == DocumentSource.CRAFT_FILE:
if (
cc_pair.connector.source == DocumentSource.CRAFT_FILE
and cc_pair.creator_id == user.id
):
return cc_pair.connector.id, cc_pair.credential.id
# Check for orphaned connector (created but cc_pair creation failed previously)
# No cc_pair for this user — find or create the shared CRAFT_FILE connector
existing_connectors = fetch_connectors(
db_session, sources=[DocumentSource.CRAFT_FILE]
)
orphaned_connector = None
connector_id: int | None = None
for conn in existing_connectors:
if conn.name != USER_LIBRARY_CONNECTOR_NAME:
continue
if not conn.credentials:
orphaned_connector = conn
if conn.name == USER_LIBRARY_CONNECTOR_NAME:
connector_id = conn.id
break
if orphaned_connector:
connector_id = orphaned_connector.id
logger.info(
f"Found orphaned User Library connector {connector_id}, completing setup"
)
else:
if connector_id is None:
connector_data = ConnectorBase(
name=USER_LIBRARY_CONNECTOR_NAME,
source=DocumentSource.CRAFT_FILE,

View File

@@ -646,16 +646,30 @@ class SessionManager:
if sandbox and sandbox.status.is_active():
# Quick health check to verify sandbox is actually responsive
if self._sandbox_manager.health_check(sandbox.id, timeout=5.0):
# AND verify the session workspace still exists on disk
# (it may have been wiped if the sandbox was re-provisioned)
is_healthy = self._sandbox_manager.health_check(sandbox.id, timeout=5.0)
workspace_exists = (
is_healthy
and self._sandbox_manager.session_workspace_exists(
sandbox.id, existing.id
)
)
if is_healthy and workspace_exists:
logger.info(
f"Returning existing empty session {existing.id} for user {user_id}"
)
return existing
else:
elif not is_healthy:
logger.warning(
f"Empty session {existing.id} has unhealthy sandbox {sandbox.id}. "
f"Deleting and creating fresh session."
)
else:
logger.warning(
f"Empty session {existing.id} workspace missing in sandbox "
f"{sandbox.id}. Deleting and creating fresh session."
)
else:
logger.warning(
f"Empty session {existing.id} has no active sandbox "
@@ -1903,6 +1917,94 @@ class SessionManager:
return zip_buffer.getvalue(), filename
def download_directory(
self,
session_id: UUID,
user_id: UUID,
path: str,
) -> tuple[bytes, str] | None:
"""
Create a zip file of an arbitrary directory in the session workspace.
Args:
session_id: The session UUID
user_id: The user ID to verify ownership
path: Relative path to the directory (within session workspace)
Returns:
Tuple of (zip_bytes, filename) or None if session not found
Raises:
ValueError: If path traversal attempted or path is not a directory
"""
# Verify session ownership
session = get_build_session(session_id, user_id, self._db_session)
if session is None:
return None
sandbox = get_sandbox_by_user_id(self._db_session, user_id)
if sandbox is None:
return None
# Check if directory exists
try:
self._sandbox_manager.list_directory(
sandbox_id=sandbox.id,
session_id=session_id,
path=path,
)
except ValueError:
return None
# Recursively collect all files
def collect_files(dir_path: str) -> list[tuple[str, str]]:
"""Collect all files recursively, returning (full_path, arcname) tuples."""
files: list[tuple[str, str]] = []
try:
entries = self._sandbox_manager.list_directory(
sandbox_id=sandbox.id,
session_id=session_id,
path=dir_path,
)
for entry in entries:
if entry.is_directory:
files.extend(collect_files(entry.path))
else:
# arcname is relative to the target directory
prefix_len = len(path) + 1 # +1 for trailing slash
arcname = entry.path[prefix_len:]
files.append((entry.path, arcname))
except ValueError:
pass
return files
file_list = collect_files(path)
# Create zip file in memory
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
for full_path, arcname in file_list:
try:
content = self._sandbox_manager.read_file(
sandbox_id=sandbox.id,
session_id=session_id,
path=full_path,
)
zip_file.writestr(arcname, content)
except ValueError:
pass
zip_buffer.seek(0)
# Use the directory name for the zip filename
dir_name = Path(path).name
safe_name = "".join(
c if c.isalnum() or c in ("-", "_", ".") else "_" for c in dir_name
)
filename = f"{safe_name}.zip"
return zip_buffer.getvalue(), filename
# =========================================================================
# File System Operations
# =========================================================================
@@ -1937,11 +2039,18 @@ class SessionManager:
return None
# Use sandbox manager to list directory (works for both local and K8s)
raw_entries = self._sandbox_manager.list_directory(
sandbox_id=sandbox.id,
session_id=session_id,
path=path,
)
# If the directory doesn't exist (e.g., session workspace not yet loaded),
# return an empty listing rather than erroring out.
try:
raw_entries = self._sandbox_manager.list_directory(
sandbox_id=sandbox.id,
session_id=session_id,
path=path,
)
except ValueError as e:
if "path traversal" in str(e).lower():
raise
return DirectoryListing(path=path, entries=[])
# Filter hidden files and directories
entries: list[FileSystemEntry] = [

View File

@@ -1,10 +1,18 @@
"use client";
import useSWR from "swr";
import Text from "@/refresh-components/texts/Text";
import Button from "@/refresh-components/buttons/Button";
import { SvgGlobe, SvgDownloadCloud } from "@opal/icons";
import { SvgGlobe, SvgDownloadCloud, SvgFolder, SvgFiles } from "@opal/icons";
import { Section } from "@/layouts/general-layouts";
import { Artifact } from "@/app/craft/hooks/useBuildSessionStore";
import { useFilesNeedsRefresh } from "@/app/craft/hooks/useBuildSessionStore";
import {
fetchDirectoryListing,
downloadArtifactFile,
downloadDirectory,
} from "@/app/craft/services/apiServices";
import { getFileIcon } from "@/lib/utils";
interface ArtifactsTabProps {
artifacts: Artifact[];
@@ -20,20 +28,50 @@ export default function ArtifactsTab({
(a) => a.type === "nextjs_app" || a.type === "web_app"
);
const handleDownload = () => {
if (!sessionId) return;
// Fetch top-level items in outputs/ directory
const filesNeedsRefresh = useFilesNeedsRefresh();
const { data: outputsListing } = useSWR(
sessionId
? [
`/api/build/sessions/${sessionId}/files?path=outputs`,
filesNeedsRefresh,
]
: null,
() => (sessionId ? fetchDirectoryListing(sessionId, "outputs") : null),
{
revalidateOnFocus: false,
dedupingInterval: 2000,
}
);
// Trigger download by creating a link and clicking it
const downloadUrl = `/api/build/sessions/${sessionId}/webapp/download`;
// Filter out the "web" directory since it's already shown as a webapp artifact
const outputEntries = (outputsListing?.entries ?? []).filter(
(entry) => entry.name !== "web"
);
const handleWebappDownload = () => {
if (!sessionId) return;
const link = document.createElement("a");
link.href = downloadUrl;
link.download = ""; // Let the server set the filename
link.href = `/api/build/sessions/${sessionId}/webapp/download`;
link.download = "";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
if (!sessionId || webappArtifacts.length === 0) {
const handleOutputDownload = (path: string, isDirectory: boolean) => {
if (!sessionId) return;
if (isDirectory) {
downloadDirectory(sessionId, path);
} else {
downloadArtifactFile(sessionId, path);
}
};
const hasWebapps = webappArtifacts.length > 0;
const hasOutputFiles = outputEntries.length > 0;
if (!sessionId || (!hasWebapps && !hasOutputFiles)) {
return (
<Section
height="full"
@@ -41,12 +79,12 @@ export default function ArtifactsTab({
justifyContent="center"
padding={2}
>
<SvgGlobe size={48} className="stroke-text-02" />
<SvgFiles size={48} className="stroke-text-02" />
<Text headingH3 text03>
No web apps yet
No artifacts yet
</Text>
<Text secondaryBody text02>
Web apps created during the build will appear here
Output files and web apps will appear here
</Text>
</Section>
);
@@ -54,24 +92,63 @@ export default function ArtifactsTab({
return (
<div className="flex flex-col h-full">
{/* Webapp Artifact List */}
<div className="flex-1 overflow-auto overlay-scrollbar">
<div className="divide-y divide-border-01">
{webappArtifacts.map((artifact) => {
{/* Webapp Artifacts */}
{webappArtifacts.map((artifact) => (
<div
key={artifact.id}
className="flex items-center gap-3 p-3 hover:bg-background-tint-01 transition-colors"
>
<SvgGlobe size={24} className="stroke-text-02 flex-shrink-0" />
<div className="flex-1 min-w-0 flex items-center gap-2">
<Text secondaryBody text04 className="truncate">
{artifact.name}
</Text>
<Text secondaryBody text02>
Next.js Application
</Text>
</div>
<div className="flex items-center gap-2">
<Button
tertiary
action
leftIcon={SvgDownloadCloud}
onClick={handleWebappDownload}
>
Download
</Button>
</div>
</div>
))}
{/* Output Files & Folders */}
{outputEntries.map((entry) => {
const FileIcon = entry.is_directory
? SvgFolder
: getFileIcon(entry.name);
return (
<div
key={artifact.id}
key={entry.path}
className="flex items-center gap-3 p-3 hover:bg-background-tint-01 transition-colors"
>
<SvgGlobe size={24} className="stroke-text-02 flex-shrink-0" />
<FileIcon size={24} className="stroke-text-02 flex-shrink-0" />
<div className="flex-1 min-w-0 flex items-center gap-2">
<Text secondaryBody text04 className="truncate">
{artifact.name}
</Text>
<Text secondaryBody text02>
Next.js Application
{entry.name}
</Text>
{entry.is_directory ? (
<Text secondaryBody text02>
Folder
</Text>
) : entry.size !== null ? (
<Text secondaryBody text02>
{formatFileSize(entry.size)}
</Text>
) : null}
</div>
<div className="flex items-center gap-2">
@@ -79,7 +156,9 @@ export default function ArtifactsTab({
tertiary
action
leftIcon={SvgDownloadCloud}
onClick={handleDownload}
onClick={() =>
handleOutputDownload(entry.path, entry.is_directory)
}
>
Download
</Button>
@@ -92,3 +171,9 @@ export default function ArtifactsTab({
</div>
);
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

View File

@@ -424,6 +424,38 @@ export async function fetchDirectoryListing(
return res.json();
}
/**
* Trigger a browser download for a single file from the sandbox.
*/
export function downloadArtifactFile(sessionId: string, path: string): void {
const encodedPath = path
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/");
const link = document.createElement("a");
link.href = `${API_BASE}/sessions/${sessionId}/artifacts/${encodedPath}`;
link.download = path.split("/").pop() || path;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
/**
* Trigger a browser download for a directory as a zip file.
*/
export function downloadDirectory(sessionId: string, path: string): void {
const encodedPath = path
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/");
const link = document.createElement("a");
link.href = `${API_BASE}/sessions/${sessionId}/download-directory/${encodedPath}`;
link.download = "";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
export interface FileContentResponse {
content: string; // For text files: text content. For images: data URL (base64-encoded)
mimeType: string;