Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 98 additions & 36 deletions packages/proxy/lib/http/response-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -788,22 +788,44 @@ const ClearCyInitialCookie: ResponseMiddleware = function () {

const MaybeEndWithEmptyBody: ResponseMiddleware = function () {
if (httpUtils.responseMustHaveEmptyBody(this.req, this.incomingRes)) {
if (this.protocolManager && this.req.browserPreRequest?.requestId) {
const requestId = getOriginalRequestId(this.req.browserPreRequest.requestId)

this.protocolManager.responseEndedWithEmptyBody({
requestId,
isCached: this.incomingRes.statusCode === 304,
timings: {
cdpRequestWillBeSentTimestamp: this.req.browserPreRequest.cdpRequestWillBeSentTimestamp,
cdpRequestWillBeSentReceivedTimestamp: this.req.browserPreRequest.cdpRequestWillBeSentReceivedTimestamp,
proxyRequestReceivedTimestamp: this.req.browserPreRequest.proxyRequestReceivedTimestamp,
cdpLagDuration: this.req.browserPreRequest.cdpLagDuration,
proxyRequestCorrelationDuration: this.req.browserPreRequest.proxyRequestCorrelationDuration,
},
})
// For empty body responses (204, 304, HEAD), we must end the response immediately
// Optionally notify protocol manager if it's enabled and Studio is active
const protocolManager = this.protocolManager

if (protocolManager && this.req.browserPreRequest?.requestId) {
const isEnabled = protocolManager.isProtocolEnabled
const mode = (protocolManager as any).mode as 'record' | 'studio' | undefined

// For studio mode, verify Studio is actually active before processing
// isProtocolEnabled already checks if _protocol exists (set in setupProtocol)
// setupProtocol is only called when Studio is initialized and canAccessStudioAI is true
// So if isProtocolEnabled is true in studio mode, Studio must be active
// However, we add an extra safety check for dbPath to ensure Studio is fully initialized
const hasDbPath = !!protocolManager.dbPath
const isStudioActive = mode === 'studio' ? hasDbPath : true
const shouldProcess = isEnabled && isStudioActive

if (shouldProcess) {
const browserPreRequest = this.req.browserPreRequest
const requestId = getOriginalRequestId(browserPreRequest.requestId)

this.debug('response-middleware:response-ended-with-empty-body:protocol-notification %o', { requestId })

protocolManager.responseEndedWithEmptyBody({
requestId,
isCached: this.incomingRes.statusCode === 304,
timings: {
cdpRequestWillBeSentTimestamp: browserPreRequest.cdpRequestWillBeSentTimestamp,
cdpRequestWillBeSentReceivedTimestamp: browserPreRequest.cdpRequestWillBeSentReceivedTimestamp,
proxyRequestReceivedTimestamp: browserPreRequest.proxyRequestReceivedTimestamp,
cdpLagDuration: browserPreRequest.cdpLagDuration,
proxyRequestCorrelationDuration: browserPreRequest.proxyRequestCorrelationDuration,
},
})
}
}

// Always end empty body responses, regardless of protocol manager state
this.res.end()

return this.end()
Expand Down Expand Up @@ -937,33 +959,73 @@ const MaybeInjectServiceWorker: ResponseMiddleware = function () {
}

const GzipBody: ResponseMiddleware = async function () {
if (this.protocolManager && this.req.browserPreRequest?.requestId) {
const preRequest = this.req.browserPreRequest
// Call protocol manager if it exists and we have a browser pre-request
// The protocol manager's invokeSync will return undefined if _protocol doesn't exist,
// so it's safe to call even when the protocol is not enabled
// However, for studio mode, we only want to actually process/record when Studio is active
const protocolManager = this.protocolManager

if (protocolManager && this.req.browserPreRequest?.requestId) {
const preRequest = this.req.browserPreRequest!
const requestId = getOriginalRequestId(preRequest.requestId)
const mode = (protocolManager as any).mode as 'record' | 'studio' | undefined
const isEnabled = protocolManager.isProtocolEnabled
const hasDbPath = !!protocolManager.dbPath

const span = telemetry.startSpan({ name: 'gzip:body:protocol-notification', parentSpan: this.resMiddlewareSpan, isVerbose })

const resultingStream = this.protocolManager.responseStreamReceived({
requestId,
responseHeaders: this.incomingRes.headers,
isAlreadyGunzipped: this.isGunzipped,
responseStream: this.incomingResStream,
res: this.res,
timings: {
cdpRequestWillBeSentTimestamp: preRequest.cdpRequestWillBeSentTimestamp,
cdpRequestWillBeSentReceivedTimestamp: preRequest.cdpRequestWillBeSentReceivedTimestamp,
proxyRequestReceivedTimestamp: preRequest.proxyRequestReceivedTimestamp,
cdpLagDuration: preRequest.cdpLagDuration,
proxyRequestCorrelationDuration: preRequest.proxyRequestCorrelationDuration,
},
})
// For studio mode, only actually process if Studio is active (dbPath exists)
// For record mode, process if protocol is enabled
// Note: We still call responseStreamReceived even if shouldProcess is false,
// because invokeSync will return undefined safely, but we skip the telemetry/logging
const shouldProcess = mode === 'studio'
? (isEnabled && hasDbPath) // Studio mode: need both enabled and dbPath
: isEnabled // Record mode: just need enabled

if (resultingStream) {
this.incomingResStream = resultingStream.on('error', this.onError).once('close', () => {
span?.end()
if (shouldProcess) {
const span = telemetry.startSpan({ name: 'gzip:body:protocol-notification', parentSpan: this.resMiddlewareSpan, isVerbose })

this.debug('response-middleware:gzip:body:protocol-notification %o', { requestId })

const resultingStream = protocolManager.responseStreamReceived({
requestId,
responseHeaders: this.incomingRes.headers,
isAlreadyGunzipped: this.isGunzipped,
responseStream: this.incomingResStream,
res: this.res,
timings: {
cdpRequestWillBeSentTimestamp: preRequest.cdpRequestWillBeSentTimestamp,
cdpRequestWillBeSentReceivedTimestamp: preRequest.cdpRequestWillBeSentReceivedTimestamp,
proxyRequestReceivedTimestamp: preRequest.proxyRequestReceivedTimestamp,
cdpLagDuration: preRequest.cdpLagDuration,
proxyRequestCorrelationDuration: preRequest.proxyRequestCorrelationDuration,
},
})

if (resultingStream) {
this.incomingResStream = resultingStream.on('error', this.onError).once('close', () => {
span?.end()
})
} else {
span?.end()
}
} else {
span?.end()
// Protocol manager exists but shouldn't process (e.g., studio mode without active Studio)
// Still call responseStreamReceived - it will return undefined safely via invokeSync
// This maintains the original behavior and ensures stream flow is not disrupted
protocolManager.responseStreamReceived({
requestId,
responseHeaders: this.incomingRes.headers,
isAlreadyGunzipped: this.isGunzipped,
responseStream: this.incomingResStream,
res: this.res,
timings: {
cdpRequestWillBeSentTimestamp: preRequest.cdpRequestWillBeSentTimestamp,
cdpRequestWillBeSentReceivedTimestamp: preRequest.cdpRequestWillBeSentReceivedTimestamp,
proxyRequestReceivedTimestamp: preRequest.proxyRequestReceivedTimestamp,
cdpLagDuration: preRequest.cdpLagDuration,
proxyRequestCorrelationDuration: preRequest.proxyRequestCorrelationDuration,
},
})
// resultingStream will be undefined, so we continue with original stream
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Protocol manager still processes streams when Studio inactive

When shouldProcess is false (Studio mode without active Studio), the code still calls protocolManager.responseStreamReceived, which processes and intercepts the response stream. The comment claims invokeSync returns undefined safely, but invokeSync only returns early if _protocol is undefined. Since isProtocolEnabled is true here, _protocol exists, so the stream gets processed anyway, defeating the PR's goal of preventing interception when Studio isn't open.

Fix in Cursor Fix in Web

}
}

Expand Down
14 changes: 14 additions & 0 deletions packages/server/lib/cloud/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,24 @@ export class ProtocolManager implements ProtocolManagerShape {

private AppCaptureProtocol: AppCaptureProtocolConstructor | undefined
private options: ProtocolManagerOptions | undefined
private _canAccessStudioAI: boolean = false

get isProtocolEnabled (): boolean {
return !!this._protocol
}

get mode (): 'record' | 'studio' | undefined {
return this.options?.mode
}

get canAccessStudioAI (): boolean {
return this._canAccessStudioAI
}

setCanAccessStudioAI (canAccess: boolean): void {
this._canAccessStudioAI = canAccess
}

get networkEnableOptions () {
return this.isProtocolEnabled ? {
maxTotalBufferSize: 0,
Expand Down Expand Up @@ -451,6 +464,7 @@ export class ProtocolManager implements ProtocolManagerShape {
this._runId = undefined
this._errors = []
this._protocol = undefined
this._canAccessStudioAI = false
}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/server/lib/project-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,7 @@ export class ProjectBase extends EE {
}

this.protocolManager = studio.protocolManager
this.protocolManager.setCanAccessStudioAI(canAccessStudioAI)
this.protocolManager.setupProtocol()
this.protocolManager.beforeSpec({
...this.spec,
Expand Down
3 changes: 3 additions & 0 deletions packages/types/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@ export type AfterSpecDurations = {

export interface ProtocolManagerShape extends AppCaptureProtocolCommon {
isProtocolEnabled: boolean
mode?: 'record' | 'studio'
canAccessStudioAI: boolean
setCanAccessStudioAI(canAccess: boolean): void
networkEnableOptions?: { maxTotalBufferSize: number, maxResourceBufferSize: number, maxPostDataSize: number }
setupProtocol(): void
prepareProtocol (script: string, options: ProtocolManagerOptions): Promise<void>
Expand Down
Loading