Compare commits

..

4 Commits

Author SHA1 Message Date
Bentlybro
719c4ee1d1 fix: add explicit ValueError guard for stat output parsing 2026-02-16 14:46:06 +00:00
Bentlybro
411c399e03 style: fix formatting and sync docs
- Fix Black formatting for is_text/is_binary checks
- Update llm.md to reflect binary file support in Claude Code block
2026-02-16 14:40:53 +00:00
Bentlybro
6ac011e36c fix: normalize extension case in sandbox file extraction
Fixes bug where 'Dockerfile' in TEXT_EXTENSIONS wouldn't match after
lowercasing file_path because the extension itself wasn't lowercased.
2026-02-16 14:18:25 +00:00
Bentlybro
5e554526e2 fix(backend): Extract binary files from ClaudeCodeBlock sandbox
Enables binary file extraction (images, PDFs, etc.) for the Claude Code block
by setting text_only=False in extract_and_store_sandbox_files.

Changes:
- sandbox_files.py: Add BINARY_EXTENSIONS set with supported formats
- sandbox_files.py: Add MAX_BINARY_FILE_SIZE (50MB) limit to prevent OOM
- sandbox_files.py: Add size check before reading binary files
- sandbox_files.py: Add .svg to TEXT_EXTENSIONS (XML-based)
- sandbox_files.py: Make extension matching case-insensitive
- claude_code.py: Enable binary file extraction (text_only=False)
- claude_code.py: Update output description to mention binary support
- claude_code.md: Update docs to reflect binary file support

Binary files are stored via store_media_file which handles:
- Virus scanning via scan_content_safe()
- Workspace storage (returns workspace:// URI in CoPilot)
- Data URI fallback for graph execution

Closes SECRT-1897
2026-02-16 14:10:05 +00:00
9 changed files with 117 additions and 485 deletions

View File

@@ -187,9 +187,11 @@ class ClaudeCodeBlock(Block):
)
files: list[SandboxFileOutput] = SchemaField(
description=(
"List of text files created/modified by Claude Code during this execution. "
"List of files created/modified by Claude Code during this execution. "
"Includes text files and binary files (images, PDFs, etc.). "
"Each file has 'path', 'relative_path', 'name', 'content', and 'workspace_ref' fields. "
"workspace_ref contains a workspace:// URI if the file was stored to workspace."
"workspace_ref contains a workspace:// URI for workspace storage. "
"For binary files, content contains a placeholder; use workspace_ref to access the file."
)
)
conversation_history: str = SchemaField(
@@ -453,12 +455,14 @@ class ClaudeCodeBlock(Block):
new_conversation_history = turn_entry
# Extract files created/modified during this run and store to workspace
# Include binary files (images, PDFs, etc.) - they'll be stored via
# store_media_file which handles virus scanning and workspace storage
sandbox_files = await extract_and_store_sandbox_files(
sandbox=sandbox,
working_directory=working_directory,
execution_context=execution_context,
since_timestamp=start_timestamp,
text_only=True,
text_only=False, # Extract both text and binary files
)
return (

View File

@@ -74,8 +74,51 @@ TEXT_EXTENSIONS = {
".tex",
".csv",
".log",
".svg", # SVG is XML-based text
}
# Binary file extensions we explicitly support extracting
# These are common output formats that users expect to retrieve
BINARY_EXTENSIONS = {
# Images
".png",
".jpg",
".jpeg",
".gif",
".webp",
".ico",
".bmp",
".tiff",
".tif",
# Documents
".pdf",
# Archives
".zip",
".tar",
".gz",
".7z",
# Audio
".mp3",
".wav",
".ogg",
".flac",
# Video
".mp4",
".webm",
".mov",
".avi",
# Fonts
".woff",
".woff2",
".ttf",
".otf",
".eot",
}
# Maximum file size for binary extraction (50MB)
# Prevents OOM from accidentally extracting huge files
MAX_BINARY_FILE_SIZE = 50 * 1024 * 1024
class SandboxFileOutput(BaseModel):
"""A file extracted from a sandbox and optionally stored in workspace."""
@@ -120,7 +163,8 @@ async def extract_sandbox_files(
sandbox: The E2B sandbox instance
working_directory: Directory to search for files
since_timestamp: ISO timestamp - only return files modified after this time
text_only: If True, only extract text files (default). If False, extract all files.
text_only: If True, only extract text files. If False, also extract
supported binary files (images, PDFs, etc.).
Returns:
List of ExtractedFile objects with path, content, and metadata
@@ -149,14 +193,53 @@ async def extract_sandbox_files(
if not file_path:
continue
# Check if it's a text file
is_text = any(file_path.endswith(ext) for ext in TEXT_EXTENSIONS)
# Check file type (case-insensitive for extensions)
file_path_lower = file_path.lower()
is_text = any(
file_path_lower.endswith(ext.lower()) for ext in TEXT_EXTENSIONS
)
is_binary = any(
file_path_lower.endswith(ext.lower()) for ext in BINARY_EXTENSIONS
)
# Skip non-text files if text_only mode
if text_only and not is_text:
continue
# Determine if we should extract this file
if text_only:
# Only extract text files
if not is_text:
continue
else:
# Extract text files and supported binary files
if not is_text and not is_binary:
continue
try:
# For binary files, check size before reading to prevent OOM
if is_binary:
stat_result = await sandbox.commands.run(
f"stat -c %s {shlex.quote(file_path)} 2>/dev/null"
)
if stat_result.exit_code != 0 or not stat_result.stdout:
logger.debug(
f"Skipping {file_path}: could not determine file size"
)
continue
try:
file_size = int(stat_result.stdout.strip())
except ValueError:
logger.debug(
f"Skipping {file_path}: unexpected stat output "
f"{stat_result.stdout.strip()!r}"
)
continue
if file_size > MAX_BINARY_FILE_SIZE:
logger.info(
f"Skipping {file_path}: size {file_size} bytes "
f"exceeds limit {MAX_BINARY_FILE_SIZE}"
)
continue
# Read file content as bytes
content = await sandbox.files.read(file_path, format="bytes")
if isinstance(content, str):

View File

@@ -1,8 +1,8 @@
"use client";
import { Button } from "@/components/atoms/Button/Button";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { SidebarProvider } from "@/components/ui/sidebar";
// TODO: Replace with modern Dialog component when available
import DeleteConfirmDialog from "@/components/__legacy__/delete-confirm-dialog";
import { ChatContainer } from "./components/ChatContainer/ChatContainer";
import { ChatSidebar } from "./components/ChatSidebar/ChatSidebar";
import { MobileDrawer } from "./components/MobileDrawer/MobileDrawer";
@@ -97,47 +97,13 @@ export function CopilotPage() {
)}
{/* Delete confirmation dialog - rendered at top level for proper z-index on mobile */}
{isMobile && (
<Dialog
title="Delete chat"
controlled={{
isOpen: !!sessionToDelete,
set: async (open) => {
if (!open && !isDeleting) {
handleCancelDelete();
}
},
}}
onClose={handleCancelDelete}
>
<Dialog.Content>
<p className="text-neutral-600">
Are you sure you want to delete{" "}
<span className="font-medium">
&quot;{sessionToDelete?.title || "Untitled chat"}&quot;
</span>
? This action cannot be undone.
</p>
<Dialog.Footer>
<Button
variant="ghost"
size="small"
onClick={handleCancelDelete}
disabled={isDeleting}
>
Cancel
</Button>
<Button
variant="primary"
size="small"
onClick={handleConfirmDelete}
loading={isDeleting}
className="bg-red-600 hover:bg-red-700"
>
Delete
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog>
<DeleteConfirmDialog
entityType="chat"
entityName={sessionToDelete?.title || "Untitled chat"}
open={!!sessionToDelete}
onOpenChange={(open) => !open && handleCancelDelete()}
onDoDelete={handleConfirmDelete}
/>
)}
</SidebarProvider>
);

View File

@@ -8,7 +8,8 @@ import { Button } from "@/components/atoms/Button/Button";
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
import { Text } from "@/components/atoms/Text/Text";
import { toast } from "@/components/molecules/Toast/use-toast";
import { DeleteChatDialog } from "../DeleteChatDialog";
// TODO: Replace with modern Dialog component when available
import DeleteConfirmDialog from "@/components/__legacy__/delete-confirm-dialog";
import {
Sidebar,
SidebarContent,
@@ -91,12 +92,6 @@ export function ChatSidebar() {
}
}
function handleCancelDelete() {
if (!isDeleting) {
setSessionToDelete(null);
}
}
function formatDate(dateString: string) {
const date = new Date(dateString);
const now = new Date();
@@ -262,11 +257,12 @@ export function ChatSidebar() {
)}
</Sidebar>
<DeleteChatDialog
session={sessionToDelete}
isDeleting={isDeleting}
onConfirm={handleConfirmDelete}
onCancel={handleCancelDelete}
<DeleteConfirmDialog
entityType="chat"
entityName={sessionToDelete?.title || "Untitled chat"}
open={!!sessionToDelete}
onOpenChange={(open) => !open && setSessionToDelete(null)}
onDoDelete={handleConfirmDelete}
/>
</>
);

View File

@@ -1,350 +0,0 @@
import { expect, test, describe, vi, beforeEach, afterEach } from "vitest";
import {
render,
screen,
waitFor,
fireEvent,
cleanup,
} from "@testing-library/react";
import { ChatSidebar } from "../ChatSidebar";
import { server } from "@/mocks/mock-server";
import {
getGetV2ListSessionsMockHandler,
getDeleteV2DeleteSessionMockHandler204,
getDeleteV2DeleteSessionMockHandler422,
} from "@/app/api/__generated__/endpoints/chat/chat.msw";
import { SidebarProvider } from "@/components/ui/sidebar";
import { NuqsTestingAdapter } from "nuqs/adapters/testing";
import { http, HttpResponse, delay } from "msw";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BackendAPIProvider } from "@/lib/autogpt-server-api/context";
// Mock sessions data
const mockSessions = {
sessions: [
{
id: "session-1",
title: "First Chat",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
{
id: "session-2",
title: "Second Chat",
created_at: new Date(Date.now() - 86400000).toISOString(),
updated_at: new Date(Date.now() - 86400000).toISOString(),
},
{
id: "session-3",
title: null,
created_at: new Date(Date.now() - 172800000).toISOString(),
updated_at: new Date(Date.now() - 172800000).toISOString(),
},
],
total: 3,
};
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
}
function TestWrapper({
children,
searchParams = "",
onUrlUpdate,
}: {
children: React.ReactNode;
searchParams?: string;
onUrlUpdate?: (event: { queryString: string }) => void;
}) {
const queryClient = createTestQueryClient();
return (
<QueryClientProvider client={queryClient}>
<BackendAPIProvider>
<NuqsTestingAdapter
searchParams={searchParams}
hasMemory
onUrlUpdate={onUrlUpdate}
>
<SidebarProvider defaultOpen={true}>{children}</SidebarProvider>
</NuqsTestingAdapter>
</BackendAPIProvider>
</QueryClientProvider>
);
}
function renderChatSidebar(
searchParams = "",
onUrlUpdate?: (event: { queryString: string }) => void,
) {
return render(
<TestWrapper searchParams={searchParams} onUrlUpdate={onUrlUpdate}>
<ChatSidebar />
</TestWrapper>,
);
}
describe("ChatSidebar", () => {
beforeEach(() => {
server.use(
getGetV2ListSessionsMockHandler(() => mockSessions),
getDeleteV2DeleteSessionMockHandler204(),
);
});
afterEach(() => {
cleanup();
});
describe("Sessions List", () => {
test("renders session list correctly", async () => {
renderChatSidebar();
// Use getAllByText since component may render multiple times
await waitFor(() => {
const elements = screen.getAllByText("First Chat");
expect(elements.length).toBeGreaterThan(0);
});
expect(screen.getAllByText("Second Chat").length).toBeGreaterThan(0);
expect(screen.getAllByText("Untitled chat").length).toBeGreaterThan(0);
});
test("shows empty state when no sessions", async () => {
server.use(
getGetV2ListSessionsMockHandler(() => ({
sessions: [],
total: 0,
})),
);
renderChatSidebar();
await waitFor(() => {
expect(
screen.getAllByText("No conversations yet").length,
).toBeGreaterThan(0);
});
});
test("formats dates correctly", async () => {
renderChatSidebar();
await waitFor(() => {
expect(screen.getAllByText("First Chat").length).toBeGreaterThan(0);
});
expect(screen.getAllByText("Today").length).toBeGreaterThan(0);
expect(screen.getAllByText("Yesterday").length).toBeGreaterThan(0);
expect(screen.getAllByText("2 days ago").length).toBeGreaterThan(0);
});
});
describe("Delete Dialog", () => {
test("opens delete dialog when trash button is clicked", async () => {
renderChatSidebar();
await waitFor(() => {
expect(screen.getAllByText("First Chat").length).toBeGreaterThan(0);
});
// Find and click the delete button for the first session
const deleteButtons = screen.getAllByLabelText("Delete chat");
fireEvent.click(deleteButtons[0]);
// Dialog should appear with confirmation text
await waitFor(() => {
expect(
screen.getByText(/Are you sure you want to delete/),
).toBeDefined();
});
});
test("closes dialog when cancel is clicked", async () => {
renderChatSidebar();
await waitFor(() => {
expect(screen.getAllByText("First Chat").length).toBeGreaterThan(0);
});
const deleteButtons = screen.getAllByLabelText("Delete chat");
fireEvent.click(deleteButtons[0]);
await waitFor(() => {
expect(
screen.getByText(/Are you sure you want to delete/),
).toBeDefined();
});
const cancelButton = screen.getByRole("button", { name: "Cancel" });
fireEvent.click(cancelButton);
await waitFor(() => {
expect(
screen.queryByText(/Are you sure you want to delete/),
).toBeNull();
});
});
test("calls delete API when delete button is clicked", async () => {
const deleteMock = vi.fn();
server.use(
http.delete(
"http://localhost:3000/api/proxy/api/chat/sessions/:sessionId",
async ({ params }) => {
deleteMock(params.sessionId);
return new HttpResponse(null, { status: 204 });
},
),
);
renderChatSidebar();
await waitFor(() => {
expect(screen.getAllByText("First Chat").length).toBeGreaterThan(0);
});
const deleteButtons = screen.getAllByLabelText("Delete chat");
fireEvent.click(deleteButtons[0]);
await waitFor(() => {
expect(
screen.getByText(/Are you sure you want to delete/),
).toBeDefined();
});
const deleteButton = screen.getByRole("button", { name: "Delete" });
fireEvent.click(deleteButton);
await waitFor(() => {
expect(deleteMock).toHaveBeenCalledWith("session-1");
});
});
test("closes dialog after successful deletion", async () => {
renderChatSidebar();
await waitFor(() => {
expect(screen.getAllByText("First Chat").length).toBeGreaterThan(0);
});
const deleteButtons = screen.getAllByLabelText("Delete chat");
fireEvent.click(deleteButtons[0]);
await waitFor(() => {
expect(
screen.getByText(/Are you sure you want to delete/),
).toBeDefined();
});
const deleteButton = screen.getByRole("button", { name: "Delete" });
fireEvent.click(deleteButton);
await waitFor(() => {
expect(
screen.queryByText(/Are you sure you want to delete/),
).toBeNull();
});
});
test("handles deletion error gracefully", async () => {
server.use(getDeleteV2DeleteSessionMockHandler422());
renderChatSidebar();
await waitFor(() => {
expect(screen.getAllByText("First Chat").length).toBeGreaterThan(0);
});
const deleteButtons = screen.getAllByLabelText("Delete chat");
fireEvent.click(deleteButtons[0]);
await waitFor(() => {
expect(
screen.getByText(/Are you sure you want to delete/),
).toBeDefined();
});
const deleteButton = screen.getByRole("button", { name: "Delete" });
fireEvent.click(deleteButton);
// Dialog should close even on error
await waitFor(() => {
expect(
screen.queryByText(/Are you sure you want to delete/),
).toBeNull();
});
});
});
describe("Session Selection", () => {
test("highlights currently selected session", async () => {
renderChatSidebar("?sessionId=session-1");
await waitFor(() => {
expect(screen.getAllByText("First Chat").length).toBeGreaterThan(0);
});
// Find the session with selected styling (text-zinc-600 indicates selected)
const selectedSessions = screen.getAllByText("First Chat");
const hasSelectedStyle = selectedSessions.some(
(el) =>
el.className.includes("text-zinc-600") ||
el.closest("div")?.className.includes("bg-zinc-100"),
);
expect(hasSelectedStyle).toBe(true);
});
test("selects session when clicked", async () => {
const urlUpdateSpy = vi.fn();
renderChatSidebar("", urlUpdateSpy);
await waitFor(() => {
expect(screen.getAllByText("First Chat").length).toBeGreaterThan(0);
});
// Find a session button and click it
const sessionElements = screen.getAllByText("First Chat");
const sessionButton = sessionElements[0].closest("button");
if (sessionButton) {
fireEvent.click(sessionButton);
}
await waitFor(() => {
expect(urlUpdateSpy).toHaveBeenCalled();
const lastCall =
urlUpdateSpy.mock.calls[urlUpdateSpy.mock.calls.length - 1];
expect(lastCall[0].queryString).toContain("sessionId=session-1");
});
});
});
describe("New Chat", () => {
test("clears session selection when new chat button is clicked", async () => {
const urlUpdateSpy = vi.fn();
renderChatSidebar("?sessionId=session-1", urlUpdateSpy);
await waitFor(() => {
expect(screen.getAllByText("First Chat").length).toBeGreaterThan(0);
});
const newChatButton = screen.getByRole("button", { name: "New Chat" });
fireEvent.click(newChatButton);
await waitFor(() => {
expect(urlUpdateSpy).toHaveBeenCalled();
const lastCall =
urlUpdateSpy.mock.calls[urlUpdateSpy.mock.calls.length - 1];
expect(lastCall[0].queryString).not.toContain("sessionId=session-1");
});
});
});
});

View File

@@ -1,66 +0,0 @@
"use client";
import { Button } from "@/components/atoms/Button/Button";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
interface DeleteChatDialogProps {
/** The session to delete, or null if dialog should be closed */
session: { id: string; title: string | null | undefined } | null;
/** Whether deletion is in progress */
isDeleting: boolean;
/** Called when user confirms deletion */
onConfirm: () => void;
/** Called when user cancels (only works when not deleting) */
onCancel: () => void;
}
export function DeleteChatDialog({
session,
isDeleting,
onConfirm,
onCancel,
}: DeleteChatDialogProps) {
return (
<Dialog
title="Delete chat"
controlled={{
isOpen: !!session,
set: async (open) => {
if (!open && !isDeleting) {
onCancel();
}
},
}}
onClose={onCancel}
>
<Dialog.Content>
<p className="text-neutral-600">
Are you sure you want to delete{" "}
<span className="font-medium">
&quot;{session?.title || "Untitled chat"}&quot;
</span>
? This action cannot be undone.
</p>
<Dialog.Footer>
<Button
variant="ghost"
size="small"
onClick={onCancel}
disabled={isDeleting}
>
Cancel
</Button>
<Button
variant="primary"
size="small"
onClick={onConfirm}
loading={isDeleting}
className="bg-red-600 hover:bg-red-700"
>
Delete
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog>
);
}

View File

@@ -1 +0,0 @@
export { DeleteChatDialog } from "./DeleteChatDialog";

View File

@@ -16,7 +16,7 @@ When activated, the block:
- Install dependencies (npm, pip, etc.)
- Run terminal commands
- Build and test applications
5. Extracts all text files created/modified during execution
5. Extracts all files created/modified during execution (text files and binary files like images, PDFs, etc.)
6. Returns the response and files, optionally keeping the sandbox alive for follow-up tasks
The block supports conversation continuation through three mechanisms:
@@ -42,7 +42,7 @@ The block supports conversation continuation through three mechanisms:
| Output | Description |
|--------|-------------|
| Response | The output/response from Claude Code execution |
| Files | List of text files created/modified during execution. Each file includes path, relative_path, name, and content fields |
| Files | List of files (text and binary) created/modified during execution. Includes images, PDFs, and other supported formats. Each file has path, relative_path, name, content, and workspace_ref fields. Binary files are stored in workspace and accessible via workspace_ref |
| Conversation History | Full conversation history including this turn. Use to restore context on a fresh sandbox |
| Session ID | Session ID for this conversation. Pass back with sandbox_id to continue the conversation |
| Sandbox ID | ID of the sandbox instance (null if disposed). Pass back with session_id to continue the conversation |

View File

@@ -535,7 +535,7 @@ When activated, the block:
2. Installs the latest version of Claude Code in the sandbox
3. Optionally runs setup commands to prepare the environment
4. Executes your prompt using Claude Code, which can create/edit files, install dependencies, run terminal commands, and build applications
5. Extracts all text files created/modified during execution
5. Extracts all files created/modified during execution (text files and binary files like images, PDFs, etc.)
6. Returns the response and files, optionally keeping the sandbox alive for follow-up tasks
The block supports conversation continuation through three mechanisms:
@@ -563,7 +563,7 @@ The block supports conversation continuation through three mechanisms:
|--------|-------------|------|
| error | Error message if execution failed | str |
| response | The output/response from Claude Code execution | str |
| files | List of text files created/modified by Claude Code during this execution. Each file has 'path', 'relative_path', 'name', 'content', and 'workspace_ref' fields. workspace_ref contains a workspace:// URI if the file was stored to workspace. | List[SandboxFileOutput] |
| files | List of files created/modified by Claude Code during this execution. Includes text files and binary files (images, PDFs, etc.). Each file has 'path', 'relative_path', 'name', 'content', and 'workspace_ref' fields. workspace_ref contains a workspace:// URI for workspace storage. For binary files, content contains a placeholder; use workspace_ref to access the file. | List[SandboxFileOutput] |
| conversation_history | Full conversation history including this turn. Pass this to conversation_history input to continue on a fresh sandbox if the previous sandbox timed out. | str |
| session_id | Session ID for this conversation. Pass this back along with sandbox_id to continue the conversation. | str |
| sandbox_id | ID of the sandbox instance. Pass this back along with session_id to continue the conversation. This is None if dispose_sandbox was True (sandbox was disposed). | str |