@@ -17,7 +17,12 @@ import {
1717} from '@/lib/copilot/vfs/operations'
1818import { decodeVfsSegment , encodeVfsSegment } from '@/lib/copilot/vfs/path-utils'
1919import { getServePathPrefix } from '@/lib/uploads'
20- import { ArchiveError , extractArchiveEntry , listArchiveEntries } from '@/lib/uploads/archive'
20+ import {
21+ ArchiveError ,
22+ extractArchiveEntry ,
23+ listArchiveEntries ,
24+ MAX_ARCHIVE_BYTES ,
25+ } from '@/lib/uploads/archive'
2126import {
2227 fetchWorkspaceFileBuffer ,
2328 type WorkspaceFileRecord ,
@@ -160,6 +165,26 @@ export function isArchiveUpload(record: WorkspaceFileRecord): boolean {
160165 return isArchiveFileName ( record . name )
161166}
162167
168+ /**
169+ * True when an archive's stored size exceeds the read cap, so it must not be
170+ * downloaded + parsed inline. Checked against `record.size` BEFORE fetching so an
171+ * oversized archive never gets buffered into memory (the decompress tool applies
172+ * the same {@link MAX_ARCHIVE_BYTES} cap on its own download path).
173+ */
174+ function exceedsArchiveReadCap ( record : WorkspaceFileRecord ) : boolean {
175+ return record . size > MAX_ARCHIVE_BYTES
176+ }
177+
178+ /** Placeholder for an archive too large to download and extract inline. */
179+ function archiveTooLargeResult ( record : WorkspaceFileRecord ) : FileReadResult {
180+ return {
181+ content : `[Archive too large to read: ${ record . name } (${ Math . round (
182+ record . size / 1024 / 1024
183+ ) } MB, limit ${ MAX_ARCHIVE_BYTES / 1024 / 1024 } MB)]`,
184+ totalLines : 1 ,
185+ }
186+ }
187+
163188/** Decode each `/`-separated segment of a VFS entry path back to its real name. */
164189function decodeEntryPath ( raw : string ) : string {
165190 return raw
@@ -233,6 +258,10 @@ export async function listChatUploadArchiveEntries(
233258 if ( ! row ) return null
234259 const record = toWorkspaceFileRecord ( row )
235260 if ( ! isArchiveUpload ( record ) ) return null
261+ if ( exceedsArchiveReadCap ( record ) ) {
262+ logger . warn ( 'Archive too large to list entries' , { zipName, chatId, size : record . size } )
263+ return [ ]
264+ }
236265
237266 const encodedZip = canonicalUploadKey ( record . name )
238267 try {
@@ -282,21 +311,24 @@ async function readArchiveEntry(
282311}
283312
284313/**
285- * Build a file-tree manifest for a bare archive read (`read("uploads/x.zip")`),
286- * so the agent gets the contents instead of binary bytes. Returns a placeholder
287- * result when the archive is unreadable.
314+ * Build a file-tree manifest for an archive (`read("uploads/x.zip")`), so the
315+ * agent gets the contents instead of binary bytes. An optional `note` is
316+ * prepended — used to tell the agent a requested entry was not found while still
317+ * showing the valid paths. Returns a placeholder result when the archive is
318+ * unreadable.
288319 */
289320async function buildArchiveManifest (
290321 record : WorkspaceFileRecord ,
291- archiveBuffer : Buffer
322+ archiveBuffer : Buffer ,
323+ note ?: string
292324) : Promise < FileReadResult > {
293325 const encodedZip = canonicalUploadKey ( record . name )
294326 try {
295327 const entries = await listArchiveEntries ( archiveBuffer )
296328 const header = `Archive "${ record . name } " — ${ entries . length } file${
297329 entries . length === 1 ? '' : 's'
298330 } . Read an entry with read("uploads/${ encodedZip } /<path>").`
299- const content = [ header , '' , ...entries ] . join ( '\n' )
331+ const content = [ ... ( note ? [ note , '' ] : [ ] ) , header , '' , ...entries ] . join ( '\n' )
300332 return { content, totalLines : content . split ( '\n' ) . length }
301333 } catch ( err ) {
302334 if ( err instanceof ArchiveError ) {
@@ -326,10 +358,23 @@ export async function readChatUploadPath(
326358 if ( ! isArchiveUpload ( record ) ) {
327359 return await readFileRecord ( record )
328360 }
361+ if ( exceedsArchiveReadCap ( record ) ) {
362+ return archiveTooLargeResult ( record )
363+ }
329364 const archiveBuffer = await fetchWorkspaceFileBuffer ( record )
330- return entryPath
331- ? await readArchiveEntry ( archiveBuffer , entryPath )
332- : await buildArchiveManifest ( record , archiveBuffer )
365+ if ( ! entryPath ) {
366+ return await buildArchiveManifest ( record , archiveBuffer )
367+ }
368+ const entry = await readArchiveEntry ( archiveBuffer , entryPath )
369+ if ( entry ) return entry
370+ // Entry not found — show the manifest so the agent can pick a valid path.
371+ // Handles a stray `/content` habit suffix (carried over from files/) and
372+ // plain typos uniformly, without special-casing any segment name.
373+ return await buildArchiveManifest (
374+ record ,
375+ archiveBuffer ,
376+ `Entry "${ decodeEntryPath ( entryPath ) } " not found in "${ record . name } ".`
377+ )
333378 } catch ( err ) {
334379 logger . warn ( 'Failed to read chat upload' , {
335380 firstSegment,
@@ -366,6 +411,11 @@ export async function grepChatUploadPath(
366411 const record = toWorkspaceFileRecord ( row )
367412
368413 if ( entryPath && isArchiveUpload ( record ) ) {
414+ if ( exceedsArchiveReadCap ( record ) ) {
415+ throw new WorkspaceFileGrepError (
416+ `Archive too large to grep: "${ record . name } " (limit ${ MAX_ARCHIVE_BYTES / 1024 / 1024 } MB).`
417+ )
418+ }
369419 const archiveBuffer = await fetchWorkspaceFileBuffer ( record )
370420 const rawPath = await findArchiveEntryRawPath ( archiveBuffer , entryPath )
371421 if ( ! rawPath ) {
0 commit comments