11import { createLogger } from '@sim/logger'
22import { generateId } from '@sim/utils/id'
33import { NextRequest } from 'next/server'
4- import { fileCopyInputSchema } from '@/lib/api/contracts/file-copy-input'
54import { v2CopyFileItemsContract } from '@/lib/api/contracts/v2/file-copy'
65import { markCopilotRequest } from '@/lib/api/server/routes/copilot-request'
76import { matchV2Route } from '@/lib/api/server/routes/in-process-transport'
7+ import { V2_PARSE_DEFAULTS } from '@/lib/api/server/routes/v2-json-route'
8+ import { DEFAULT_MAX_JSON_BODY_BYTES , parseRequest } from '@/lib/api/server/validation'
89import { withWorkspaceInvocationScope } from '@/lib/core/application/workspace-invocation-scope'
910import { asOrchestrationError , statusForOrchestrationError } from '@/lib/core/orchestration/types'
11+ import {
12+ assertContentLengthWithinLimit ,
13+ isPayloadSizeLimitError ,
14+ readStreamToBufferWithLimit ,
15+ } from '@/lib/core/utils/stream-limits'
1016import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
1117import { createResourceEffectTransport } from '@/lib/mothership/agent-cli/resource-effects'
1218import { resolveInvocationWorkspace } from '@/lib/mothership/application/workspace-target'
@@ -27,9 +33,10 @@ const logger = createLogger('MothershipSandboxResourceTransport')
2733
2834/** Private callback observes the real authenticated v2 request without changing its body or API contract. */
2935export async function proxySandboxResourceRequest (
30- request : Request ,
36+ callback : Request ,
3137 token : string
3238) : Promise < Response > {
39+ let request = callback
3340 const url = new URL ( request . url )
3441 const prefix = `/api/mothership/sandbox/${ token } `
3542 const path = url . pathname . slice ( prefix . length )
@@ -51,11 +58,13 @@ export async function proxySandboxResourceRequest(
5158 * owner is refused there; workspace copies and invalid bodies keep the workspace route and its
5259 * v2 validation response.
5360 */
54- if (
55- path === v2CopyFileItemsContract . path &&
56- ( scope . fileOwnerProtocolVersion === 1 || ( await copiesBeyondWorkspaces ( request ) ) )
57- )
58- return proxySandboxFileCopyRequest ( request , token , scope )
61+ if ( path === v2CopyFileItemsContract . path ) {
62+ const buffered = await bufferCopyRequest ( request )
63+ if ( buffered instanceof Response ) return buffered
64+ request = buffered
65+ if ( scope . fileOwnerProtocolVersion === 1 || ( await copiesBeyondWorkspaces ( request ) ) )
66+ return proxySandboxFileCopyRequest ( request , token , scope )
67+ }
5968 const ownerHeader = request . headers . get ( 'x-mothership-file-owner' )
6069 let owner : FileOperationOwner | undefined
6170 if ( ownerHeader !== null ) {
@@ -203,18 +212,42 @@ async function proxyAuthorizedSandboxRequest(
203212 } )
204213}
205214
206- /** A valid copy naming a non-workspace source or destination needs the file-owner protocol. */
207- async function copiesBeyondWorkspaces ( request : Request ) : Promise < boolean > {
208- let body : unknown
215+ /**
216+ * Reads a copy body once, under the v2 body cap, so the protocol check and whichever copy path
217+ * handles it each read an in-memory copy. Parsing a clone of the live stream instead would hang on
218+ * an oversized body: cancelling one branch of a teed stream waits for the unread other branch.
219+ */
220+ async function bufferCopyRequest ( request : Request ) : Promise < Request | Response > {
221+ if ( ! request . body ) return request
209222 try {
210- body = await request . clone ( ) . json ( )
211- } catch {
212- return false
223+ assertContentLengthWithinLimit ( request . headers , DEFAULT_MAX_JSON_BODY_BYTES , 'Request body' )
224+ const body = await readStreamToBufferWithLimit ( request . body , {
225+ maxBytes : DEFAULT_MAX_JSON_BODY_BYTES ,
226+ label : 'Request body' ,
227+ signal : request . signal ,
228+ } )
229+ return new Request ( request , { body : new Uint8Array ( body ) } )
230+ } catch ( error ) {
231+ if ( isPayloadSizeLimitError ( error ) ) return V2_PARSE_DEFAULTS . payloadTooLargeResponse ( )
232+ throw error
213233 }
214- const parsed = fileCopyInputSchema . safeParse ( body )
234+ }
235+
236+ /**
237+ * A valid copy naming a non-workspace source or destination needs the file-owner protocol. Parsed
238+ * as the copy route parses it: an invalid body stays on the workspace route, which returns its own
239+ * 400.
240+ */
241+ async function copiesBeyondWorkspaces ( request : Request ) : Promise < boolean > {
242+ const parsed = await parseRequest (
243+ v2CopyFileItemsContract ,
244+ new NextRequest ( request . clone ( ) ) ,
245+ { } ,
246+ V2_PARSE_DEFAULTS
247+ )
215248 return (
216249 parsed . success &&
217- ( parsed . data . source . owner . entityType !== 'workspace' ||
218- parsed . data . destination . owner . entityType !== 'workspace' )
250+ ( parsed . data . body . source . owner . entityType !== 'workspace' ||
251+ parsed . data . body . destination . owner . entityType !== 'workspace' )
219252 )
220253}
0 commit comments