From f24f257a4ce65149576669f5909685cd423429b2 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Sat, 21 Jun 2025 16:06:49 -0500 Subject: [PATCH 01/15] internal: (studio) add manifest for all of the cloud delivered files --- .../lib/cloud/api/studio/get_studio_bundle.ts | 9 ++- packages/server/lib/cloud/encryption.ts | 4 +- .../cloud/studio/StudioLifecycleManager.ts | 13 ++++- .../lib/cloud/studio/ensure_studio_bundle.ts | 9 +-- packages/server/lib/cloud/studio/studio.ts | 14 ++++- .../api/studio/get_studio_bundle_spec.ts | 39 +++++++------ .../studio/StudioLifecycleManager_spec.ts | 57 ++++++++++++++++++- .../cloud/studio/ensure_studio_bundle_spec.ts | 10 +++- .../test/unit/cloud/studio/studio_spec.ts | 3 + .../types/src/studio/studio-server-types.ts | 3 + 10 files changed, 127 insertions(+), 34 deletions(-) diff --git a/packages/server/lib/cloud/api/studio/get_studio_bundle.ts b/packages/server/lib/cloud/api/studio/get_studio_bundle.ts index 6347b3f1046..a170bed1d37 100644 --- a/packages/server/lib/cloud/api/studio/get_studio_bundle.ts +++ b/packages/server/lib/cloud/api/studio/get_studio_bundle.ts @@ -10,10 +10,9 @@ import { verifySignatureFromFile } from '../../encryption' const pkg = require('@packages/root') const _delay = linearDelay(500) -export const getStudioBundle = async ({ studioUrl, projectId, bundlePath }: { studioUrl: string, projectId?: string, bundlePath: string }) => { +export const getStudioBundle = async ({ studioUrl, bundlePath }: { studioUrl: string, bundlePath: string }): Promise> => { let responseSignature: string | null = null - - await (asyncRetry(async () => { + const manifest = await (asyncRetry(async () => { const response = await fetch(studioUrl, { // @ts-expect-error - this is supported agent, @@ -44,6 +43,8 @@ export const getStudioBundle = async ({ studioUrl, projectId, bundlePath }: { st // @ts-expect-error - this is supported response.body?.pipe(writeStream) }) + + return JSON.parse(response.headers.get('x-cypress-manifest') || '{}') }, { maxAttempts: 3, retryDelay: _delay, @@ -59,4 +60,6 @@ export const getStudioBundle = async ({ studioUrl, projectId, bundlePath }: { st if (!verified) { throw new Error('Unable to verify studio signature') } + + return manifest } diff --git a/packages/server/lib/cloud/encryption.ts b/packages/server/lib/cloud/encryption.ts index b8fc892788b..2b762d0c6b1 100644 --- a/packages/server/lib/cloud/encryption.ts +++ b/packages/server/lib/cloud/encryption.ts @@ -1,4 +1,4 @@ -import crypto from 'crypto' +import crypto, { BinaryLike } from 'crypto' import { TextEncoder, promisify } from 'util' import { generalDecrypt, GeneralJWE } from 'jose' import base64Url from 'base64url' @@ -37,7 +37,7 @@ export interface EncryptRequestData { secretKey: crypto.KeyObject } -export function verifySignature (body: string, signature: string, publicKey?: crypto.KeyObject) { +export function verifySignature (body: BinaryLike, signature: string, publicKey?: crypto.KeyObject) { const verify = crypto.createVerify('SHA256') verify.update(body) diff --git a/packages/server/lib/cloud/studio/StudioLifecycleManager.ts b/packages/server/lib/cloud/studio/StudioLifecycleManager.ts index 7235f655462..d6201ca66d8 100644 --- a/packages/server/lib/cloud/studio/StudioLifecycleManager.ts +++ b/packages/server/lib/cloud/studio/StudioLifecycleManager.ts @@ -22,12 +22,13 @@ import { initializeTelemetryReporter, reportTelemetry } from './telemetry/Teleme import { telemetryManager } from './telemetry/TelemetryManager' import { BUNDLE_LIFECYCLE_MARK_NAMES, BUNDLE_LIFECYCLE_TELEMETRY_GROUP_NAMES } from './telemetry/constants/bundle-lifecycle' import { INITIALIZATION_TELEMETRY_GROUP_NAMES } from './telemetry/constants/initialization' +import { verifySignature } from '../encryption' const debug = Debug('cypress:server:studio-lifecycle-manager') const routes = require('../routes') export class StudioLifecycleManager { - private static hashLoadingMap: Map> = new Map() + private static hashLoadingMap: Map>> = new Map() private static watcher: chokidar.FSWatcher | null = null private studioManagerPromise?: Promise private studioManager?: StudioManager @@ -157,6 +158,7 @@ export class StudioLifecycleManager { }): Promise { let studioPath: string let studioHash: string + let manifest: Record initializeTelemetryReporter({ projectSlug: projectId, @@ -190,10 +192,11 @@ export class StudioLifecycleManager { StudioLifecycleManager.hashLoadingMap.set(studioHash, hashLoadingPromise) } - await hashLoadingPromise + manifest = await hashLoadingPromise } else { studioPath = process.env.CYPRESS_LOCAL_STUDIO_PATH studioHash = 'local' + manifest = {} } telemetryManager.mark(BUNDLE_LIFECYCLE_MARK_NAMES.ENSURE_STUDIO_BUNDLE_END) @@ -201,6 +204,11 @@ export class StudioLifecycleManager { const serverFilePath = path.join(studioPath, 'server', 'index.js') const script = await readFile(serverFilePath, 'utf8') + + if (!process.env.CYPRESS_LOCAL_STUDIO_PATH && !verifySignature(script, manifest[path.join('server', 'index.js')])) { + throw new Error('Invalid signature for studio server script') + } + const studioManager = new StudioManager() telemetryManager.mark(BUNDLE_LIFECYCLE_MARK_NAMES.STUDIO_MANAGER_SETUP_START) @@ -220,6 +228,7 @@ export class StudioLifecycleManager { asyncRetry, }, shouldEnableStudio: this.cloudStudioRequested, + manifest, }) telemetryManager.mark(BUNDLE_LIFECYCLE_MARK_NAMES.STUDIO_MANAGER_SETUP_END) diff --git a/packages/server/lib/cloud/studio/ensure_studio_bundle.ts b/packages/server/lib/cloud/studio/ensure_studio_bundle.ts index 5b4e09ca39b..86f5c3467a6 100644 --- a/packages/server/lib/cloud/studio/ensure_studio_bundle.ts +++ b/packages/server/lib/cloud/studio/ensure_studio_bundle.ts @@ -26,7 +26,7 @@ export const ensureStudioBundle = async ({ projectId, studioPath, downloadTimeoutMs = DOWNLOAD_TIMEOUT, -}: EnsureStudioBundleOptions) => { +}: EnsureStudioBundleOptions): Promise> => { const bundlePath = path.join(studioPath, 'bundle.tar') // First remove studioPath to ensure we have a clean slate @@ -35,10 +35,9 @@ export const ensureStudioBundle = async ({ let timeoutId: NodeJS.Timeout - await Promise.race([ + const manifest = await Promise.race([ getStudioBundle({ studioUrl, - projectId, bundlePath, }), new Promise((_, reject) => { @@ -48,10 +47,12 @@ export const ensureStudioBundle = async ({ }), ]).finally(() => { clearTimeout(timeoutId) - }) + }) as Promise> await tar.extract({ file: bundlePath, cwd: studioPath, }) + + return manifest } diff --git a/packages/server/lib/cloud/studio/studio.ts b/packages/server/lib/cloud/studio/studio.ts index b095c9e8512..803871e7664 100644 --- a/packages/server/lib/cloud/studio/studio.ts +++ b/packages/server/lib/cloud/studio/studio.ts @@ -5,6 +5,7 @@ import Debug from 'debug' import { requireScript } from '../require_script' import path from 'path' import { reportStudioError, ReportStudioErrorOptions } from '../api/studio/report_studio_error' +import { verifySignature } from '../encryption' interface StudioServer { default: StudioServerDefaultShape } @@ -15,6 +16,7 @@ interface SetupOptions { projectSlug?: string cloudApi: StudioCloudApi shouldEnableStudio: boolean + manifest: Record } const debug = Debug('cypress:server:studio') @@ -41,7 +43,7 @@ export class StudioManager implements StudioManagerShape { return manager } - async setup ({ script, studioPath, studioHash, projectSlug, cloudApi, shouldEnableStudio }: SetupOptions): Promise { + async setup ({ script, studioPath, studioHash, projectSlug, cloudApi, shouldEnableStudio, manifest }: SetupOptions): Promise { const { createStudioServer } = requireScript(script).default this._studioServer = await createStudioServer({ @@ -50,6 +52,16 @@ export class StudioManager implements StudioManagerShape { projectSlug, cloudApi, betterSqlite3Path: path.dirname(require.resolve('better-sqlite3/package.json')), + manifest, + verifySignature: (script, signature) => { + // If we are running locally, we don't need to verify the signature. This + // environment variable will get stripped in the binary. + if (process.env.CYPRESS_LOCAL_STUDIO_PATH) { + return true + } + + return verifySignature(script, signature) + }, }) this.status = shouldEnableStudio ? 'ENABLED' : 'INITIALIZED' diff --git a/packages/server/test/unit/cloud/api/studio/get_studio_bundle_spec.ts b/packages/server/test/unit/cloud/api/studio/get_studio_bundle_spec.ts index 7b22a25897d..d5544fdc1ea 100644 --- a/packages/server/test/unit/cloud/api/studio/get_studio_bundle_spec.ts +++ b/packages/server/test/unit/cloud/api/studio/get_studio_bundle_spec.ts @@ -9,6 +9,9 @@ describe('getStudioBundle', () => { let crossFetchStub: sinon.SinonStub let verifySignatureFromFileStub: sinon.SinonStub let getStudioBundle: typeof import('../../../../../lib/cloud/api/studio/get_studio_bundle').getStudioBundle + const mockManifest = { + 'server/index.js': 'abcdefg', + } beforeEach(() => { createWriteStreamStub = sinon.stub() @@ -53,15 +56,17 @@ describe('getStudioBundle', () => { if (header === 'x-cypress-signature') { return '159' } + + if (header === 'x-cypress-manifest') { + return JSON.stringify(mockManifest) + } }, }, }) verifySignatureFromFileStub.resolves(true) - const projectId = '12345' - - await getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/studio/abc/bundle.tar' }) + const manifest = await getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' }) expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', { agent: sinon.match.any, @@ -78,6 +83,8 @@ describe('getStudioBundle', () => { expect(writeResult).to.eq('console.log("studio bundle")') expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/studio/abc/bundle.tar', '159') + + expect(manifest).to.deep.eq(mockManifest) }) it('downloads the studio bundle and extracts it after 1 fetch failure', async () => { @@ -91,15 +98,17 @@ describe('getStudioBundle', () => { if (header === 'x-cypress-signature') { return '159' } + + if (header === 'x-cypress-manifest') { + return JSON.stringify(mockManifest) + } }, }, }) verifySignatureFromFileStub.resolves(true) - const projectId = '12345' - - await getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/studio/abc/bundle.tar' }) + const manifest = await getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' }) expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', { agent: sinon.match.any, @@ -116,6 +125,8 @@ describe('getStudioBundle', () => { expect(writeResult).to.eq('console.log("studio bundle")') expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/studio/abc/bundle.tar', '159') + + expect(manifest).to.deep.eq(mockManifest) }) it('throws an error and returns a studio manager in error state if the fetch fails more than twice', async () => { @@ -123,9 +134,7 @@ describe('getStudioBundle', () => { crossFetchStub.rejects(error) - const projectId = '12345' - - await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected + await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected expect(crossFetchStub).to.be.calledThrice expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', { @@ -147,9 +156,7 @@ describe('getStudioBundle', () => { statusText: 'Some failure', }) - const projectId = '12345' - - await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected + await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', { agent: sinon.match.any, @@ -182,9 +189,7 @@ describe('getStudioBundle', () => { verifySignatureFromFileStub.resolves(false) - const projectId = '12345' - - await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected + await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected expect(writeResult).to.eq('console.log("studio bundle")') @@ -213,9 +218,7 @@ describe('getStudioBundle', () => { }, }) - const projectId = '12345' - - await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected + await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', { agent: sinon.match.any, diff --git a/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts b/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts index dbd6c31e425..4af47657073 100644 --- a/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts +++ b/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts @@ -43,6 +43,8 @@ describe('StudioLifecycleManager', () => { let markStub: sinon.SinonStub let initializeTelemetryReporterStub: sinon.SinonStub let reportTelemetryStub: sinon.SinonStub + let verifySignatureStub: sinon.SinonStub + const mockContents = 'console.log("studio script")' beforeEach(() => { postStudioSessionStub = sinon.stub() @@ -65,6 +67,8 @@ describe('StudioLifecycleManager', () => { destroy: studioManagerDestroyStub.resolves(), } as unknown as StudioManager + verifySignatureStub = sinon.stub() + readFileStub = sinon.stub() reportTelemetryStub = sinon.stub() @@ -83,7 +87,7 @@ describe('StudioLifecycleManager', () => { }, }, 'fs-extra': { - readFile: readFileStub.resolves('console.log("studio script")'), + readFile: readFileStub.resolves(mockContents), }, '../get_cloud_metadata': { getCloudMetadata: sinon.stub().resolves({ @@ -113,6 +117,9 @@ describe('StudioLifecycleManager', () => { initializeTelemetryReporter: initializeTelemetryReporterStub, reportTelemetry: reportTelemetryStub, }, + '../encryption': { + verifySignature: verifySignatureStub.resolves(), + }, }).StudioLifecycleManager studioLifecycleManager = new StudioLifecycleManager() @@ -215,6 +222,13 @@ describe('StudioLifecycleManager', () => { }) }) + const mockManifest = { + 'server/index.js': 'abcdefg', + } + + ensureStudioBundleStub.resolves(mockManifest) + verifySignatureStub.returns(true) + await studioReadyPromise expect(mockCtx.update).to.be.calledOnce @@ -237,12 +251,15 @@ describe('StudioLifecycleManager', () => { asyncRetry, }, shouldEnableStudio: false, + manifest: mockManifest, }) expect(postStudioSessionStub).to.be.calledWith({ projectId: 'test-project-id', }) + expect(verifySignatureStub).to.be.calledWith(mockContents, 'abcdefg') + expect(readFileStub).to.be.calledWith(path.join(os.tmpdir(), 'cypress', 'studio', 'abc', 'server', 'index.js'), 'utf8') expect(getCaptureProtocolScriptStub).not.to.be.called @@ -292,6 +309,13 @@ describe('StudioLifecycleManager', () => { }) }) + const mockManifest = { + 'server/index.js': 'abcdefg', + } + + ensureStudioBundleStub.resolves(mockManifest) + verifySignatureStub.returns(true) + await studioReadyPromise expect(mockCtx.update).to.be.calledOnce @@ -314,12 +338,15 @@ describe('StudioLifecycleManager', () => { asyncRetry, }, shouldEnableStudio: false, + manifest: mockManifest, }) expect(postStudioSessionStub).to.be.calledWith({ projectId: 'test-project-id', }) + expect(verifySignatureStub).to.be.calledWith(mockContents, 'abcdefg') + expect(readFileStub).to.be.calledWith(path.join(os.tmpdir(), 'cypress', 'studio', 'abc', 'server', 'index.js'), 'utf8') expect(getCaptureProtocolScriptStub).to.be.calledWith('https://cloud.cypress.io/capture-protocol/script/def.js') @@ -389,6 +416,13 @@ describe('StudioLifecycleManager', () => { }) }) + const mockManifest = { + 'server/index.js': 'abcdefg', + } + + ensureStudioBundleStub.resolves(mockManifest) + verifySignatureStub.returns(true) + await studioReadyPromise expect(mockCtx.update).to.be.calledOnce @@ -407,12 +441,15 @@ describe('StudioLifecycleManager', () => { asyncRetry, }, shouldEnableStudio: true, + manifest: {}, }) expect(postStudioSessionStub).to.be.calledWith({ projectId: 'test-project-id', }) + expect(verifySignatureStub).not.to.be.called + expect(readFileStub).to.be.calledWith(path.join('/path', 'to', 'studio', 'server', 'index.js'), 'utf8') expect(getCaptureProtocolScriptStub).to.be.calledWith('https://cloud.cypress.io/capture-protocol/script/def.js') @@ -590,6 +627,15 @@ describe('StudioLifecycleManager', () => { }) describe('registerStudioReadyListener', () => { + beforeEach(() => { + const mockManifest = { + 'server/index.js': 'abcdefg', + } + + ensureStudioBundleStub.resolves(mockManifest) + verifySignatureStub.returns(true) + }) + it('registers a listener that will be called when studio is ready', () => { const listener = sinon.stub() @@ -735,6 +781,15 @@ describe('StudioLifecycleManager', () => { }) describe('status tracking', () => { + beforeEach(() => { + const mockManifest = { + 'server/index.js': 'abcdefg', + } + + ensureStudioBundleStub.resolves(mockManifest) + verifySignatureStub.returns(true) + }) + it('updates status and emits events when status changes', async () => { // Setup the context to test status updates // @ts-expect-error - accessing private property diff --git a/packages/server/test/unit/cloud/studio/ensure_studio_bundle_spec.ts b/packages/server/test/unit/cloud/studio/ensure_studio_bundle_spec.ts index 2fbb9dc63d0..876fe9f126e 100644 --- a/packages/server/test/unit/cloud/studio/ensure_studio_bundle_spec.ts +++ b/packages/server/test/unit/cloud/studio/ensure_studio_bundle_spec.ts @@ -11,6 +11,9 @@ describe('ensureStudioBundle', () => { let readFileStub: sinon.SinonStub = sinon.stub() let extractStub: sinon.SinonStub = sinon.stub() let getStudioBundleStub: sinon.SinonStub = sinon.stub() + const mockManifest = { + 'server/index.js': 'abcdefg', + } beforeEach(() => { rmStub = sinon.stub() @@ -35,7 +38,7 @@ describe('ensureStudioBundle', () => { extract: extractStub.resolves(), }, '../api/studio/get_studio_bundle': { - getStudioBundle: getStudioBundleStub.resolves(), + getStudioBundle: getStudioBundleStub.resolves(mockManifest), }, })).ensureStudioBundle }) @@ -44,7 +47,7 @@ describe('ensureStudioBundle', () => { const studioPath = path.join(os.tmpdir(), 'cypress', 'studio', '123') const bundlePath = path.join(studioPath, 'bundle.tar') - await ensureStudioBundle({ + const manifest = await ensureStudioBundle({ studioPath, studioUrl: 'https://cypress.io/studio', projectId: '123', @@ -54,7 +57,6 @@ describe('ensureStudioBundle', () => { expect(ensureStub).to.be.calledWith(studioPath) expect(getStudioBundleStub).to.be.calledWith({ studioUrl: 'https://cypress.io/studio', - projectId: '123', bundlePath, }) @@ -62,6 +64,8 @@ describe('ensureStudioBundle', () => { file: bundlePath, cwd: studioPath, }) + + expect(manifest).to.deep.eq(mockManifest) }) it('should throw an error if the studio bundle download times out', async () => { diff --git a/packages/server/test/unit/cloud/studio/studio_spec.ts b/packages/server/test/unit/cloud/studio/studio_spec.ts index 0261119d46b..245921849f4 100644 --- a/packages/server/test/unit/cloud/studio/studio_spec.ts +++ b/packages/server/test/unit/cloud/studio/studio_spec.ts @@ -37,6 +37,9 @@ describe('lib/cloud/studio', () => { projectSlug: '1234', cloudApi: {} as any, shouldEnableStudio: true, + manifest: { + 'server/index.js': 'abcdefg', + }, }) studio = (studioManager as any)._studioServer diff --git a/packages/types/src/studio/studio-server-types.ts b/packages/types/src/studio/studio-server-types.ts index e87f6603bfe..b332200660a 100644 --- a/packages/types/src/studio/studio-server-types.ts +++ b/packages/types/src/studio/studio-server-types.ts @@ -3,6 +3,7 @@ import type { Router } from 'express' import type { AxiosInstance } from 'axios' import type { Socket } from 'socket.io' +import type { BinaryLike } from 'crypto' export const StudioMetricsTypes = { STUDIO_STARTED: 'studio:started', @@ -50,6 +51,8 @@ export interface StudioServerOptions { projectSlug?: string cloudApi: StudioCloudApi betterSqlite3Path: string + manifest: Record + verifySignature: (script: BinaryLike, signature: string) => boolean } export interface StudioAIInitializeOptions { From 0092de134c1d5d99e61bf7ec6ec5f604321279bf Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Sat, 21 Jun 2025 21:22:53 -0500 Subject: [PATCH 02/15] fix tests and environment variables --- .../lib/cloud/studio/StudioLifecycleManager.ts | 9 +++++++-- scripts/after-pack-hook.js | 12 ++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/server/lib/cloud/studio/StudioLifecycleManager.ts b/packages/server/lib/cloud/studio/StudioLifecycleManager.ts index d6201ca66d8..23ba0ce59d2 100644 --- a/packages/server/lib/cloud/studio/StudioLifecycleManager.ts +++ b/packages/server/lib/cloud/studio/StudioLifecycleManager.ts @@ -205,8 +205,13 @@ export class StudioLifecycleManager { const script = await readFile(serverFilePath, 'utf8') - if (!process.env.CYPRESS_LOCAL_STUDIO_PATH && !verifySignature(script, manifest[path.join('server', 'index.js')])) { - throw new Error('Invalid signature for studio server script') + const signature = manifest[path.join('server', 'index.js')] + + // TODO: once the services have deployed, we should remove this check + if (signature) { + if (!process.env.CYPRESS_LOCAL_STUDIO_PATH && !verifySignature(script, signature)) { + throw new Error('Invalid signature for studio server script') + } } const studioManager = new StudioManager() diff --git a/scripts/after-pack-hook.js b/scripts/after-pack-hook.js index ea0e365c283..632c2250d3c 100644 --- a/scripts/after-pack-hook.js +++ b/scripts/after-pack-hook.js @@ -91,14 +91,22 @@ module.exports = async function (params) { const encryptionFileSource = await getEncryptionFileSource(encryptionFilePath) const cloudEnvironmentFilePath = path.join(CY_ROOT_DIR, 'packages/server/lib/cloud/environment.ts') const cloudEnvironmentFileSource = await getCloudEnvironmentFileSource(cloudEnvironmentFilePath) + + // Remove local protocol env const cloudApiFilePath = path.join(CY_ROOT_DIR, 'packages/server/lib/cloud/api/index.ts') const cloudApiFileSource = await getProtocolFileSource(cloudApiFilePath) const cloudProtocolFilePath = path.join(CY_ROOT_DIR, 'packages/server/lib/cloud/protocol.ts') const cloudProtocolFileSource = await getProtocolFileSource(cloudProtocolFilePath) + + // Remove local studio env const reportStudioErrorPath = path.join(CY_ROOT_DIR, 'packages/server/lib/cloud/api/studio/report_studio_error.ts') const reportStudioErrorFileSource = await getStudioFileSource(reportStudioErrorPath) const StudioLifecycleManagerPath = path.join(CY_ROOT_DIR, 'packages/server/lib/cloud/studio/StudioLifecycleManager.ts') const StudioLifecycleManagerFileSource = await getStudioFileSource(StudioLifecycleManagerPath) + const studioProtocolFilePath = path.join(CY_ROOT_DIR, 'packages/server/lib/cloud/protocol.ts') + const studioProtocolFileSource = await getStudioFileSource(studioProtocolFilePath) + const studioPath = path.join(CY_ROOT_DIR, 'packages/server/lib/cloud/studio/studio.ts') + const studioPathFileSource = await getStudioFileSource(studioPath) await Promise.all([ fs.writeFile(encryptionFilePath, encryptionFileSource), @@ -107,6 +115,8 @@ module.exports = async function (params) { fs.writeFile(cloudProtocolFilePath, cloudProtocolFileSource), fs.writeFile(reportStudioErrorPath, reportStudioErrorFileSource), fs.writeFile(StudioLifecycleManagerPath, StudioLifecycleManagerFileSource), + fs.writeFile(studioProtocolFilePath, studioProtocolFileSource), + fs.writeFile(studioPath, studioPathFileSource), fs.writeFile(path.join(outputFolder, 'index.js'), binaryEntryPointSource), ]) @@ -121,6 +131,8 @@ module.exports = async function (params) { validateProtocolFile(cloudProtocolFilePath), validateStudioFile(reportStudioErrorPath), validateStudioFile(StudioLifecycleManagerPath), + validateStudioFile(studioProtocolFilePath), + validateStudioFile(studioPath), ]) await flipFuses( From 640e055d80f6d6cb42462a89781d8272b3fa4bb6 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Sat, 21 Jun 2025 23:47:09 -0500 Subject: [PATCH 03/15] update strategy --- .../lib/cloud/api/studio/get_studio_bundle.ts | 16 ++--- .../cloud/studio/StudioLifecycleManager.ts | 12 ++-- .../lib/cloud/studio/ensure_studio_bundle.ts | 18 +++-- packages/server/lib/cloud/studio/studio.ts | 8 ++- .../api/studio/get_studio_bundle_spec.ts | 69 ++----------------- .../studio/StudioLifecycleManager_spec.ts | 27 ++------ .../cloud/studio/ensure_studio_bundle_spec.ts | 27 +++++++- .../types/src/studio/studio-server-types.ts | 2 +- 8 files changed, 64 insertions(+), 115 deletions(-) diff --git a/packages/server/lib/cloud/api/studio/get_studio_bundle.ts b/packages/server/lib/cloud/api/studio/get_studio_bundle.ts index a170bed1d37..77bf1711ff3 100644 --- a/packages/server/lib/cloud/api/studio/get_studio_bundle.ts +++ b/packages/server/lib/cloud/api/studio/get_studio_bundle.ts @@ -5,14 +5,14 @@ import os from 'os' import { agent } from '@packages/network' import { PUBLIC_KEY_VERSION } from '../../constants' import { createWriteStream } from 'fs' -import { verifySignatureFromFile } from '../../encryption' const pkg = require('@packages/root') const _delay = linearDelay(500) -export const getStudioBundle = async ({ studioUrl, bundlePath }: { studioUrl: string, bundlePath: string }): Promise> => { +export const getStudioBundle = async ({ studioUrl, bundlePath }: { studioUrl: string, bundlePath: string }): Promise => { let responseSignature: string | null = null - const manifest = await (asyncRetry(async () => { + + await (asyncRetry(async () => { const response = await fetch(studioUrl, { // @ts-expect-error - this is supported agent, @@ -43,8 +43,6 @@ export const getStudioBundle = async ({ studioUrl, bundlePath }: { studioUrl: st // @ts-expect-error - this is supported response.body?.pipe(writeStream) }) - - return JSON.parse(response.headers.get('x-cypress-manifest') || '{}') }, { maxAttempts: 3, retryDelay: _delay, @@ -55,11 +53,5 @@ export const getStudioBundle = async ({ studioUrl, bundlePath }: { studioUrl: st throw new Error('Unable to get studio signature') } - const verified = await verifySignatureFromFile(bundlePath, responseSignature) - - if (!verified) { - throw new Error('Unable to verify studio signature') - } - - return manifest + return responseSignature } diff --git a/packages/server/lib/cloud/studio/StudioLifecycleManager.ts b/packages/server/lib/cloud/studio/StudioLifecycleManager.ts index 23ba0ce59d2..391b81363aa 100644 --- a/packages/server/lib/cloud/studio/StudioLifecycleManager.ts +++ b/packages/server/lib/cloud/studio/StudioLifecycleManager.ts @@ -22,7 +22,7 @@ import { initializeTelemetryReporter, reportTelemetry } from './telemetry/Teleme import { telemetryManager } from './telemetry/TelemetryManager' import { BUNDLE_LIFECYCLE_MARK_NAMES, BUNDLE_LIFECYCLE_TELEMETRY_GROUP_NAMES } from './telemetry/constants/bundle-lifecycle' import { INITIALIZATION_TELEMETRY_GROUP_NAMES } from './telemetry/constants/initialization' -import { verifySignature } from '../encryption' +import crypto from 'crypto' const debug = Debug('cypress:server:studio-lifecycle-manager') const routes = require('../routes') @@ -205,12 +205,14 @@ export class StudioLifecycleManager { const script = await readFile(serverFilePath, 'utf8') - const signature = manifest[path.join('server', 'index.js')] + const expectedHash = manifest[path.join('server', 'index.js')] // TODO: once the services have deployed, we should remove this check - if (signature) { - if (!process.env.CYPRESS_LOCAL_STUDIO_PATH && !verifySignature(script, signature)) { - throw new Error('Invalid signature for studio server script') + if (expectedHash) { + const actualHash = crypto.createHash('sha256').update(script).digest('hex') + + if (!process.env.CYPRESS_LOCAL_STUDIO_PATH && actualHash !== expectedHash) { + throw new Error('Invalid hash for studio server script') } } diff --git a/packages/server/lib/cloud/studio/ensure_studio_bundle.ts b/packages/server/lib/cloud/studio/ensure_studio_bundle.ts index 86f5c3467a6..b11106e9e4a 100644 --- a/packages/server/lib/cloud/studio/ensure_studio_bundle.ts +++ b/packages/server/lib/cloud/studio/ensure_studio_bundle.ts @@ -1,8 +1,9 @@ -import { remove, ensureDir } from 'fs-extra' +import { remove, ensureDir, readFile } from 'fs-extra' import tar from 'tar' import { getStudioBundle } from '../api/studio/get_studio_bundle' import path from 'path' +import { verifySignature } from '../encryption' interface EnsureStudioBundleOptions { studioUrl: string @@ -35,7 +36,7 @@ export const ensureStudioBundle = async ({ let timeoutId: NodeJS.Timeout - const manifest = await Promise.race([ + const responseSignature = await Promise.race([ getStudioBundle({ studioUrl, bundlePath, @@ -47,12 +48,21 @@ export const ensureStudioBundle = async ({ }), ]).finally(() => { clearTimeout(timeoutId) - }) as Promise> + }) as string await tar.extract({ file: bundlePath, cwd: studioPath, }) - return manifest + const manifestPath = path.join(studioPath, 'manifest.json') + const manifestContents = await readFile(manifestPath, 'utf8') + + const verified = await verifySignature(manifestContents, responseSignature) + + if (!verified) { + throw new Error('Unable to verify studio signature') + } + + return JSON.parse(manifestContents) } diff --git a/packages/server/lib/cloud/studio/studio.ts b/packages/server/lib/cloud/studio/studio.ts index 803871e7664..7b0c4cae362 100644 --- a/packages/server/lib/cloud/studio/studio.ts +++ b/packages/server/lib/cloud/studio/studio.ts @@ -5,7 +5,7 @@ import Debug from 'debug' import { requireScript } from '../require_script' import path from 'path' import { reportStudioError, ReportStudioErrorOptions } from '../api/studio/report_studio_error' -import { verifySignature } from '../encryption' +import crypto, { BinaryLike } from 'crypto' interface StudioServer { default: StudioServerDefaultShape } @@ -53,14 +53,16 @@ export class StudioManager implements StudioManagerShape { cloudApi, betterSqlite3Path: path.dirname(require.resolve('better-sqlite3/package.json')), manifest, - verifySignature: (script, signature) => { + verifyHash: (contents: BinaryLike, expectedHash: string) => { // If we are running locally, we don't need to verify the signature. This // environment variable will get stripped in the binary. if (process.env.CYPRESS_LOCAL_STUDIO_PATH) { return true } - return verifySignature(script, signature) + const actualHash = crypto.createHash('sha256').update(contents).digest('hex') + + return actualHash === expectedHash }, }) diff --git a/packages/server/test/unit/cloud/api/studio/get_studio_bundle_spec.ts b/packages/server/test/unit/cloud/api/studio/get_studio_bundle_spec.ts index d5544fdc1ea..a6c7bd02cb5 100644 --- a/packages/server/test/unit/cloud/api/studio/get_studio_bundle_spec.ts +++ b/packages/server/test/unit/cloud/api/studio/get_studio_bundle_spec.ts @@ -7,16 +7,11 @@ describe('getStudioBundle', () => { let readStream: Readable let createWriteStreamStub: sinon.SinonStub let crossFetchStub: sinon.SinonStub - let verifySignatureFromFileStub: sinon.SinonStub let getStudioBundle: typeof import('../../../../../lib/cloud/api/studio/get_studio_bundle').getStudioBundle - const mockManifest = { - 'server/index.js': 'abcdefg', - } beforeEach(() => { createWriteStreamStub = sinon.stub() crossFetchStub = sinon.stub() - verifySignatureFromFileStub = sinon.stub() readStream = Readable.from('console.log("studio bundle")') writeResult = '' @@ -34,9 +29,6 @@ describe('getStudioBundle', () => { createWriteStream: createWriteStreamStub, }, 'cross-fetch': crossFetchStub, - '../../encryption': { - verifySignatureFromFile: verifySignatureFromFileStub, - }, 'os': { platform: () => 'linux', }, @@ -56,17 +48,11 @@ describe('getStudioBundle', () => { if (header === 'x-cypress-signature') { return '159' } - - if (header === 'x-cypress-manifest') { - return JSON.stringify(mockManifest) - } }, }, }) - verifySignatureFromFileStub.resolves(true) - - const manifest = await getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' }) + const responseSignature = await getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' }) expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', { agent: sinon.match.any, @@ -82,9 +68,7 @@ describe('getStudioBundle', () => { expect(writeResult).to.eq('console.log("studio bundle")') - expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/studio/abc/bundle.tar', '159') - - expect(manifest).to.deep.eq(mockManifest) + expect(responseSignature).to.eq('159') }) it('downloads the studio bundle and extracts it after 1 fetch failure', async () => { @@ -98,17 +82,11 @@ describe('getStudioBundle', () => { if (header === 'x-cypress-signature') { return '159' } - - if (header === 'x-cypress-manifest') { - return JSON.stringify(mockManifest) - } }, }, }) - verifySignatureFromFileStub.resolves(true) - - const manifest = await getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' }) + const responseSignature = await getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' }) expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', { agent: sinon.match.any, @@ -124,9 +102,7 @@ describe('getStudioBundle', () => { expect(writeResult).to.eq('console.log("studio bundle")') - expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/studio/abc/bundle.tar', '159') - - expect(manifest).to.deep.eq(mockManifest) + expect(responseSignature).to.eq('159') }) it('throws an error and returns a studio manager in error state if the fetch fails more than twice', async () => { @@ -171,43 +147,6 @@ describe('getStudioBundle', () => { }) }) - it('throws an error and returns a studio manager in error state if the signature verification fails', async () => { - verifySignatureFromFileStub.resolves(false) - - crossFetchStub.resolves({ - ok: true, - statusText: 'OK', - body: readStream, - headers: { - get: (header) => { - if (header === 'x-cypress-signature') { - return '159' - } - }, - }, - }) - - verifySignatureFromFileStub.resolves(false) - - await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected - - expect(writeResult).to.eq('console.log("studio bundle")') - - expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', { - agent: sinon.match.any, - method: 'GET', - headers: { - 'x-route-version': '1', - 'x-cypress-signature': '1', - 'x-os-name': 'linux', - 'x-cypress-version': '1.2.3', - }, - encrypt: 'signed', - }) - - expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/studio/abc/bundle.tar', '159') - }) - it('throws an error if there is no signature in the response headers', async () => { crossFetchStub.resolves({ ok: true, diff --git a/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts b/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts index 4af47657073..6b97a1b204b 100644 --- a/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts +++ b/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts @@ -43,7 +43,6 @@ describe('StudioLifecycleManager', () => { let markStub: sinon.SinonStub let initializeTelemetryReporterStub: sinon.SinonStub let reportTelemetryStub: sinon.SinonStub - let verifySignatureStub: sinon.SinonStub const mockContents = 'console.log("studio script")' beforeEach(() => { @@ -67,8 +66,6 @@ describe('StudioLifecycleManager', () => { destroy: studioManagerDestroyStub.resolves(), } as unknown as StudioManager - verifySignatureStub = sinon.stub() - readFileStub = sinon.stub() reportTelemetryStub = sinon.stub() @@ -117,9 +114,6 @@ describe('StudioLifecycleManager', () => { initializeTelemetryReporter: initializeTelemetryReporterStub, reportTelemetry: reportTelemetryStub, }, - '../encryption': { - verifySignature: verifySignatureStub.resolves(), - }, }).StudioLifecycleManager studioLifecycleManager = new StudioLifecycleManager() @@ -223,11 +217,10 @@ describe('StudioLifecycleManager', () => { }) const mockManifest = { - 'server/index.js': 'abcdefg', + 'server/index.js': 'e1ed3dc8ba9eb8ece23914004b99ad97bba37e80a25d8b47c009e1e4948a6159', } ensureStudioBundleStub.resolves(mockManifest) - verifySignatureStub.returns(true) await studioReadyPromise @@ -258,8 +251,6 @@ describe('StudioLifecycleManager', () => { projectId: 'test-project-id', }) - expect(verifySignatureStub).to.be.calledWith(mockContents, 'abcdefg') - expect(readFileStub).to.be.calledWith(path.join(os.tmpdir(), 'cypress', 'studio', 'abc', 'server', 'index.js'), 'utf8') expect(getCaptureProtocolScriptStub).not.to.be.called @@ -310,11 +301,10 @@ describe('StudioLifecycleManager', () => { }) const mockManifest = { - 'server/index.js': 'abcdefg', + 'server/index.js': 'e1ed3dc8ba9eb8ece23914004b99ad97bba37e80a25d8b47c009e1e4948a6159', } ensureStudioBundleStub.resolves(mockManifest) - verifySignatureStub.returns(true) await studioReadyPromise @@ -345,8 +335,6 @@ describe('StudioLifecycleManager', () => { projectId: 'test-project-id', }) - expect(verifySignatureStub).to.be.calledWith(mockContents, 'abcdefg') - expect(readFileStub).to.be.calledWith(path.join(os.tmpdir(), 'cypress', 'studio', 'abc', 'server', 'index.js'), 'utf8') expect(getCaptureProtocolScriptStub).to.be.calledWith('https://cloud.cypress.io/capture-protocol/script/def.js') @@ -417,11 +405,10 @@ describe('StudioLifecycleManager', () => { }) const mockManifest = { - 'server/index.js': 'abcdefg', + 'server/index.js': 'e1ed3dc8ba9eb8ece23914004b99ad97bba37e80a25d8b47c009e1e4948a6159', } ensureStudioBundleStub.resolves(mockManifest) - verifySignatureStub.returns(true) await studioReadyPromise @@ -448,8 +435,6 @@ describe('StudioLifecycleManager', () => { projectId: 'test-project-id', }) - expect(verifySignatureStub).not.to.be.called - expect(readFileStub).to.be.calledWith(path.join('/path', 'to', 'studio', 'server', 'index.js'), 'utf8') expect(getCaptureProtocolScriptStub).to.be.calledWith('https://cloud.cypress.io/capture-protocol/script/def.js') @@ -629,11 +614,10 @@ describe('StudioLifecycleManager', () => { describe('registerStudioReadyListener', () => { beforeEach(() => { const mockManifest = { - 'server/index.js': 'abcdefg', + 'server/index.js': 'e1ed3dc8ba9eb8ece23914004b99ad97bba37e80a25d8b47c009e1e4948a6159', } ensureStudioBundleStub.resolves(mockManifest) - verifySignatureStub.returns(true) }) it('registers a listener that will be called when studio is ready', () => { @@ -783,11 +767,10 @@ describe('StudioLifecycleManager', () => { describe('status tracking', () => { beforeEach(() => { const mockManifest = { - 'server/index.js': 'abcdefg', + 'server/index.js': 'e1ed3dc8ba9eb8ece23914004b99ad97bba37e80a25d8b47c009e1e4948a6159', } ensureStudioBundleStub.resolves(mockManifest) - verifySignatureStub.returns(true) }) it('updates status and emits events when status changes', async () => { diff --git a/packages/server/test/unit/cloud/studio/ensure_studio_bundle_spec.ts b/packages/server/test/unit/cloud/studio/ensure_studio_bundle_spec.ts index 876fe9f126e..a71f5a325fb 100644 --- a/packages/server/test/unit/cloud/studio/ensure_studio_bundle_spec.ts +++ b/packages/server/test/unit/cloud/studio/ensure_studio_bundle_spec.ts @@ -8,9 +8,11 @@ describe('ensureStudioBundle', () => { let rmStub: sinon.SinonStub = sinon.stub() let ensureStub: sinon.SinonStub = sinon.stub() let copyStub: sinon.SinonStub = sinon.stub() - let readFileStub: sinon.SinonStub = sinon.stub() let extractStub: sinon.SinonStub = sinon.stub() let getStudioBundleStub: sinon.SinonStub = sinon.stub() + let readFileStub: sinon.SinonStub = sinon.stub() + let verifySignatureStub: sinon.SinonStub = sinon.stub() + const mockResponseSignature = '159' const mockManifest = { 'server/index.js': 'abcdefg', } @@ -22,6 +24,7 @@ describe('ensureStudioBundle', () => { readFileStub = sinon.stub() extractStub = sinon.stub() getStudioBundleStub = sinon.stub() + verifySignatureStub = sinon.stub() ensureStudioBundle = (proxyquire('../lib/cloud/studio/ensure_studio_bundle', { os: { @@ -32,13 +35,16 @@ describe('ensureStudioBundle', () => { remove: rmStub.resolves(), ensureDir: ensureStub.resolves(), copy: copyStub.resolves(), - readFile: readFileStub.resolves('console.log("studio bundle")'), + readFile: readFileStub.resolves(JSON.stringify(mockManifest)), }, tar: { extract: extractStub.resolves(), }, '../api/studio/get_studio_bundle': { - getStudioBundle: getStudioBundleStub.resolves(mockManifest), + getStudioBundle: getStudioBundleStub.resolves(mockResponseSignature), + }, + '../encryption': { + verifySignature: verifySignatureStub.resolves(true), }, })).ensureStudioBundle }) @@ -55,6 +61,7 @@ describe('ensureStudioBundle', () => { expect(rmStub).to.be.calledWith(studioPath) expect(ensureStub).to.be.calledWith(studioPath) + expect(readFileStub).to.be.calledWith(path.join(studioPath, 'manifest.json'), 'utf8') expect(getStudioBundleStub).to.be.calledWith({ studioUrl: 'https://cypress.io/studio', bundlePath, @@ -65,9 +72,23 @@ describe('ensureStudioBundle', () => { cwd: studioPath, }) + expect(verifySignatureStub).to.be.calledWith(JSON.stringify(mockManifest), mockResponseSignature) + expect(manifest).to.deep.eq(mockManifest) }) + it('should throw an error if the studio bundle signature is invalid', async () => { + verifySignatureStub.resolves(false) + + const ensureStudioBundlePromise = ensureStudioBundle({ + studioPath: '/tmp/cypress/studio/123', + studioUrl: 'https://cypress.io/studio', + projectId: '123', + }) + + await expect(ensureStudioBundlePromise).to.be.rejectedWith('Unable to verify studio signature') + }) + it('should throw an error if the studio bundle download times out', async () => { getStudioBundleStub.callsFake(() => { return new Promise((resolve) => { diff --git a/packages/types/src/studio/studio-server-types.ts b/packages/types/src/studio/studio-server-types.ts index b332200660a..2f9489b5a09 100644 --- a/packages/types/src/studio/studio-server-types.ts +++ b/packages/types/src/studio/studio-server-types.ts @@ -52,7 +52,7 @@ export interface StudioServerOptions { cloudApi: StudioCloudApi betterSqlite3Path: string manifest: Record - verifySignature: (script: BinaryLike, signature: string) => boolean + verifyHash: (contents: BinaryLike, expectedHash: string) => boolean } export interface StudioAIInitializeOptions { From f14dc7c4ec241911887af32032d421d7ad9068b7 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Sun, 22 Jun 2025 08:18:57 -0500 Subject: [PATCH 04/15] fix tests --- .../lib/cloud/studio/ensure_studio_bundle.ts | 8 +++++++- .../cloud/studio/ensure_studio_bundle_spec.ts | 3 +++ scripts/after-pack-hook.js | 16 +++++++++++----- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/server/lib/cloud/studio/ensure_studio_bundle.ts b/packages/server/lib/cloud/studio/ensure_studio_bundle.ts index b11106e9e4a..a7ff4d1bf78 100644 --- a/packages/server/lib/cloud/studio/ensure_studio_bundle.ts +++ b/packages/server/lib/cloud/studio/ensure_studio_bundle.ts @@ -1,4 +1,4 @@ -import { remove, ensureDir, readFile } from 'fs-extra' +import { remove, ensureDir, readFile, pathExists } from 'fs-extra' import tar from 'tar' import { getStudioBundle } from '../api/studio/get_studio_bundle' @@ -56,6 +56,12 @@ export const ensureStudioBundle = async ({ }) const manifestPath = path.join(studioPath, 'manifest.json') + + if (!(await pathExists(manifestPath))) { + // TODO: Eventually throw an error here once everything lands in production + return {} + } + const manifestContents = await readFile(manifestPath, 'utf8') const verified = await verifySignature(manifestContents, responseSignature) diff --git a/packages/server/test/unit/cloud/studio/ensure_studio_bundle_spec.ts b/packages/server/test/unit/cloud/studio/ensure_studio_bundle_spec.ts index a71f5a325fb..f917c62a54f 100644 --- a/packages/server/test/unit/cloud/studio/ensure_studio_bundle_spec.ts +++ b/packages/server/test/unit/cloud/studio/ensure_studio_bundle_spec.ts @@ -12,6 +12,7 @@ describe('ensureStudioBundle', () => { let getStudioBundleStub: sinon.SinonStub = sinon.stub() let readFileStub: sinon.SinonStub = sinon.stub() let verifySignatureStub: sinon.SinonStub = sinon.stub() + let pathExistsStub: sinon.SinonStub = sinon.stub() const mockResponseSignature = '159' const mockManifest = { 'server/index.js': 'abcdefg', @@ -25,6 +26,7 @@ describe('ensureStudioBundle', () => { extractStub = sinon.stub() getStudioBundleStub = sinon.stub() verifySignatureStub = sinon.stub() + pathExistsStub = sinon.stub() ensureStudioBundle = (proxyquire('../lib/cloud/studio/ensure_studio_bundle', { os: { @@ -36,6 +38,7 @@ describe('ensureStudioBundle', () => { ensureDir: ensureStub.resolves(), copy: copyStub.resolves(), readFile: readFileStub.resolves(JSON.stringify(mockManifest)), + pathExists: pathExistsStub.resolves(true), }, tar: { extract: extractStub.resolves(), diff --git a/scripts/after-pack-hook.js b/scripts/after-pack-hook.js index 632c2250d3c..4ce763e6076 100644 --- a/scripts/after-pack-hook.js +++ b/scripts/after-pack-hook.js @@ -92,12 +92,23 @@ module.exports = async function (params) { const cloudEnvironmentFilePath = path.join(CY_ROOT_DIR, 'packages/server/lib/cloud/environment.ts') const cloudEnvironmentFileSource = await getCloudEnvironmentFileSource(cloudEnvironmentFilePath) + await Promise.all([ + fs.writeFile(path.join(outputFolder, 'index.js'), binaryByteNodeEntryPointSource), + fs.writeFile(encryptionFilePath, encryptionFileSource), + fs.writeFile(cloudEnvironmentFilePath, cloudEnvironmentFileSource), + ]) + // Remove local protocol env const cloudApiFilePath = path.join(CY_ROOT_DIR, 'packages/server/lib/cloud/api/index.ts') const cloudApiFileSource = await getProtocolFileSource(cloudApiFilePath) const cloudProtocolFilePath = path.join(CY_ROOT_DIR, 'packages/server/lib/cloud/protocol.ts') const cloudProtocolFileSource = await getProtocolFileSource(cloudProtocolFilePath) + await Promise.all([ + fs.writeFile(cloudApiFilePath, cloudApiFileSource), + fs.writeFile(cloudProtocolFilePath, cloudProtocolFileSource), + ]) + // Remove local studio env const reportStudioErrorPath = path.join(CY_ROOT_DIR, 'packages/server/lib/cloud/api/studio/report_studio_error.ts') const reportStudioErrorFileSource = await getStudioFileSource(reportStudioErrorPath) @@ -109,15 +120,10 @@ module.exports = async function (params) { const studioPathFileSource = await getStudioFileSource(studioPath) await Promise.all([ - fs.writeFile(encryptionFilePath, encryptionFileSource), - fs.writeFile(cloudEnvironmentFilePath, cloudEnvironmentFileSource), - fs.writeFile(cloudApiFilePath, cloudApiFileSource), - fs.writeFile(cloudProtocolFilePath, cloudProtocolFileSource), fs.writeFile(reportStudioErrorPath, reportStudioErrorFileSource), fs.writeFile(StudioLifecycleManagerPath, StudioLifecycleManagerFileSource), fs.writeFile(studioProtocolFilePath, studioProtocolFileSource), fs.writeFile(studioPath, studioPathFileSource), - fs.writeFile(path.join(outputFolder, 'index.js'), binaryEntryPointSource), ]) const integrityCheckSource = getIntegrityCheckSource(outputFolder) From 4592b72ef0e16e62f67ce3fbfacec8469f574dad Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Sun, 22 Jun 2025 21:18:00 -0500 Subject: [PATCH 05/15] rework --- .../lib/cloud/api/studio/get_studio_bundle.ts | 13 +++- .../lib/cloud/studio/ensure_studio_bundle.ts | 6 +- .../api/studio/get_studio_bundle_spec.ts | 66 ++++++++++++++++++- 3 files changed, 78 insertions(+), 7 deletions(-) diff --git a/packages/server/lib/cloud/api/studio/get_studio_bundle.ts b/packages/server/lib/cloud/api/studio/get_studio_bundle.ts index 77bf1711ff3..f870bd35deb 100644 --- a/packages/server/lib/cloud/api/studio/get_studio_bundle.ts +++ b/packages/server/lib/cloud/api/studio/get_studio_bundle.ts @@ -5,12 +5,14 @@ import os from 'os' import { agent } from '@packages/network' import { PUBLIC_KEY_VERSION } from '../../constants' import { createWriteStream } from 'fs' +import { verifySignatureFromFile } from '../../encryption' const pkg = require('@packages/root') const _delay = linearDelay(500) -export const getStudioBundle = async ({ studioUrl, bundlePath }: { studioUrl: string, bundlePath: string }): Promise => { +export const getStudioBundle = async ({ studioUrl, bundlePath }: { studioUrl: string, bundlePath: string }): Promise => { let responseSignature: string | null = null + let responseManifestSignature: string | null = null await (asyncRetry(async () => { const response = await fetch(studioUrl, { @@ -31,6 +33,7 @@ export const getStudioBundle = async ({ studioUrl, bundlePath }: { studioUrl: st } responseSignature = response.headers.get('x-cypress-signature') + responseManifestSignature = response.headers.get('x-cypress-manifest-signature') await new Promise((resolve, reject) => { const writeStream = createWriteStream(bundlePath) @@ -53,5 +56,11 @@ export const getStudioBundle = async ({ studioUrl, bundlePath }: { studioUrl: st throw new Error('Unable to get studio signature') } - return responseSignature + const verified = await verifySignatureFromFile(bundlePath, responseSignature) + + if (!verified) { + throw new Error('Unable to verify studio signature') + } + + return responseManifestSignature } diff --git a/packages/server/lib/cloud/studio/ensure_studio_bundle.ts b/packages/server/lib/cloud/studio/ensure_studio_bundle.ts index a7ff4d1bf78..74d458d3b8c 100644 --- a/packages/server/lib/cloud/studio/ensure_studio_bundle.ts +++ b/packages/server/lib/cloud/studio/ensure_studio_bundle.ts @@ -36,7 +36,7 @@ export const ensureStudioBundle = async ({ let timeoutId: NodeJS.Timeout - const responseSignature = await Promise.race([ + const responseManifestSignature = await Promise.race([ getStudioBundle({ studioUrl, bundlePath, @@ -57,14 +57,14 @@ export const ensureStudioBundle = async ({ const manifestPath = path.join(studioPath, 'manifest.json') - if (!(await pathExists(manifestPath))) { + if (!responseManifestSignature || !(await pathExists(manifestPath))) { // TODO: Eventually throw an error here once everything lands in production return {} } const manifestContents = await readFile(manifestPath, 'utf8') - const verified = await verifySignature(manifestContents, responseSignature) + const verified = await verifySignature(manifestContents, responseManifestSignature) if (!verified) { throw new Error('Unable to verify studio signature') diff --git a/packages/server/test/unit/cloud/api/studio/get_studio_bundle_spec.ts b/packages/server/test/unit/cloud/api/studio/get_studio_bundle_spec.ts index a6c7bd02cb5..5e0edbd4847 100644 --- a/packages/server/test/unit/cloud/api/studio/get_studio_bundle_spec.ts +++ b/packages/server/test/unit/cloud/api/studio/get_studio_bundle_spec.ts @@ -7,11 +7,13 @@ describe('getStudioBundle', () => { let readStream: Readable let createWriteStreamStub: sinon.SinonStub let crossFetchStub: sinon.SinonStub + let verifySignatureFromFileStub: sinon.SinonStub let getStudioBundle: typeof import('../../../../../lib/cloud/api/studio/get_studio_bundle').getStudioBundle beforeEach(() => { createWriteStreamStub = sinon.stub() crossFetchStub = sinon.stub() + verifySignatureFromFileStub = sinon.stub() readStream = Readable.from('console.log("studio bundle")') writeResult = '' @@ -35,6 +37,9 @@ describe('getStudioBundle', () => { '@packages/root': { version: '1.2.3', }, + '../../encryption': { + verifySignatureFromFile: verifySignatureFromFileStub, + }, }).getStudioBundle }) @@ -48,10 +53,16 @@ describe('getStudioBundle', () => { if (header === 'x-cypress-signature') { return '159' } + + if (header === 'x-cypress-manifest-signature') { + return '160' + } }, }, }) + verifySignatureFromFileStub.resolves(true) + const responseSignature = await getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' }) expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', { @@ -68,7 +79,9 @@ describe('getStudioBundle', () => { expect(writeResult).to.eq('console.log("studio bundle")') - expect(responseSignature).to.eq('159') + expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/studio/abc/bundle.tar', '159') + + expect(responseSignature).to.eq('160') }) it('downloads the studio bundle and extracts it after 1 fetch failure', async () => { @@ -82,10 +95,16 @@ describe('getStudioBundle', () => { if (header === 'x-cypress-signature') { return '159' } + + if (header === 'x-cypress-manifest-signature') { + return '160' + } }, }, }) + verifySignatureFromFileStub.resolves(true) + const responseSignature = await getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' }) expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', { @@ -102,7 +121,9 @@ describe('getStudioBundle', () => { expect(writeResult).to.eq('console.log("studio bundle")') - expect(responseSignature).to.eq('159') + expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/studio/abc/bundle.tar', '159') + + expect(responseSignature).to.eq('160') }) it('throws an error and returns a studio manager in error state if the fetch fails more than twice', async () => { @@ -147,6 +168,47 @@ describe('getStudioBundle', () => { }) }) + it('throws an error and returns a cy-prompt manager in error state if the signature verification fails', async () => { + verifySignatureFromFileStub.resolves(false) + + crossFetchStub.resolves({ + ok: true, + statusText: 'OK', + body: readStream, + headers: { + get: (header) => { + if (header === 'x-cypress-signature') { + return '159' + } + + if (header === 'x-cypress-manifest-signature') { + return '160' + } + }, + }, + }) + + verifySignatureFromFileStub.resolves(false) + + await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected + + expect(writeResult).to.eq('console.log("studio bundle")') + + expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', { + agent: sinon.match.any, + method: 'GET', + headers: { + 'x-route-version': '1', + 'x-cypress-signature': '1', + 'x-os-name': 'linux', + 'x-cypress-version': '1.2.3', + }, + encrypt: 'signed', + }) + + expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/studio/abc/bundle.tar', '159') + }) + it('throws an error if there is no signature in the response headers', async () => { crossFetchStub.resolves({ ok: true, From 46103cc2bcbfb856ce96e1abc6da1f03a6cb8ce9 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Sun, 22 Jun 2025 21:58:57 -0500 Subject: [PATCH 06/15] Apply suggestions from code review --- scripts/after-pack-hook.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/after-pack-hook.js b/scripts/after-pack-hook.js index 4ce763e6076..607fcfe6ed9 100644 --- a/scripts/after-pack-hook.js +++ b/scripts/after-pack-hook.js @@ -93,7 +93,7 @@ module.exports = async function (params) { const cloudEnvironmentFileSource = await getCloudEnvironmentFileSource(cloudEnvironmentFilePath) await Promise.all([ - fs.writeFile(path.join(outputFolder, 'index.js'), binaryByteNodeEntryPointSource), + fs.writeFile(path.join(outputFolder, 'index.js'), binaryEntryPointSource), fs.writeFile(encryptionFilePath, encryptionFileSource), fs.writeFile(cloudEnvironmentFilePath, cloudEnvironmentFileSource), ]) From e038467c0005cd2cea36d21926e9b7c98e0bf8d7 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Wed, 25 Jun 2025 21:07:37 -0500 Subject: [PATCH 07/15] require manifest --- .../server/lib/cloud/studio/ensure_studio_bundle.ts | 5 ++--- .../unit/cloud/studio/ensure_studio_bundle_spec.ts | 12 ++++++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/server/lib/cloud/studio/ensure_studio_bundle.ts b/packages/server/lib/cloud/studio/ensure_studio_bundle.ts index 74d458d3b8c..78a62dea5a0 100644 --- a/packages/server/lib/cloud/studio/ensure_studio_bundle.ts +++ b/packages/server/lib/cloud/studio/ensure_studio_bundle.ts @@ -57,9 +57,8 @@ export const ensureStudioBundle = async ({ const manifestPath = path.join(studioPath, 'manifest.json') - if (!responseManifestSignature || !(await pathExists(manifestPath))) { - // TODO: Eventually throw an error here once everything lands in production - return {} + if (!(await pathExists(manifestPath))) { + throw new Error('Unable to find studio manifest') } const manifestContents = await readFile(manifestPath, 'utf8') diff --git a/packages/server/test/unit/cloud/studio/ensure_studio_bundle_spec.ts b/packages/server/test/unit/cloud/studio/ensure_studio_bundle_spec.ts index f917c62a54f..2cc63729a46 100644 --- a/packages/server/test/unit/cloud/studio/ensure_studio_bundle_spec.ts +++ b/packages/server/test/unit/cloud/studio/ensure_studio_bundle_spec.ts @@ -92,6 +92,18 @@ describe('ensureStudioBundle', () => { await expect(ensureStudioBundlePromise).to.be.rejectedWith('Unable to verify studio signature') }) + it('should throw an error if the studio bundle manifest is not found', async () => { + pathExistsStub.resolves(false) + + const ensureStudioBundlePromise = ensureStudioBundle({ + studioPath: '/tmp/cypress/studio/123', + studioUrl: 'https://cypress.io/studio', + projectId: '123', + }) + + await expect(ensureStudioBundlePromise).to.be.rejectedWith('Unable to find studio manifest') + }) + it('should throw an error if the studio bundle download times out', async () => { getStudioBundleStub.callsFake(() => { return new Promise((resolve) => { From fdda331b6bf9756a1c5c01e86c935cc2b955a2da Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Wed, 25 Jun 2025 21:17:38 -0500 Subject: [PATCH 08/15] require manifest --- .../lib/cloud/api/studio/get_studio_bundle.ts | 6 ++- .../api/studio/get_studio_bundle_spec.ts | 37 ++++++++++++++++++- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/packages/server/lib/cloud/api/studio/get_studio_bundle.ts b/packages/server/lib/cloud/api/studio/get_studio_bundle.ts index f870bd35deb..465fb9e2a72 100644 --- a/packages/server/lib/cloud/api/studio/get_studio_bundle.ts +++ b/packages/server/lib/cloud/api/studio/get_studio_bundle.ts @@ -10,7 +10,7 @@ import { verifySignatureFromFile } from '../../encryption' const pkg = require('@packages/root') const _delay = linearDelay(500) -export const getStudioBundle = async ({ studioUrl, bundlePath }: { studioUrl: string, bundlePath: string }): Promise => { +export const getStudioBundle = async ({ studioUrl, bundlePath }: { studioUrl: string, bundlePath: string }): Promise => { let responseSignature: string | null = null let responseManifestSignature: string | null = null @@ -56,6 +56,10 @@ export const getStudioBundle = async ({ studioUrl, bundlePath }: { studioUrl: st throw new Error('Unable to get studio signature') } + if (!responseManifestSignature) { + throw new Error('Unable to get studio manifest signature') + } + const verified = await verifySignatureFromFile(bundlePath, responseSignature) if (!verified) { diff --git a/packages/server/test/unit/cloud/api/studio/get_studio_bundle_spec.ts b/packages/server/test/unit/cloud/api/studio/get_studio_bundle_spec.ts index 5e0edbd4847..210f265ff60 100644 --- a/packages/server/test/unit/cloud/api/studio/get_studio_bundle_spec.ts +++ b/packages/server/test/unit/cloud/api/studio/get_studio_bundle_spec.ts @@ -215,11 +215,44 @@ describe('getStudioBundle', () => { statusText: 'OK', body: readStream, headers: { - get: () => null, + get: (header) => { + if (header === 'x-cypress-manifest-signature') { + return '160' + } + }, }, }) - await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected + await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejectedWith('Unable to get studio signature') + + expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', { + agent: sinon.match.any, + method: 'GET', + headers: { + 'x-route-version': '1', + 'x-cypress-signature': '1', + 'x-os-name': 'linux', + 'x-cypress-version': '1.2.3', + }, + encrypt: 'signed', + }) + }) + + it('throws an error if there is no manifest signature in the response headers', async () => { + crossFetchStub.resolves({ + ok: true, + statusText: 'OK', + body: readStream, + headers: { + get: (header) => { + if (header === 'x-cypress-signature') { + return '159' + } + }, + }, + }) + + await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejectedWith('Unable to get studio manifest signature') expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', { agent: sinon.match.any, From aebfb85645675917b2e1cf7de905f7b7859aa710 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Mon, 30 Jun 2025 17:25:39 -0500 Subject: [PATCH 09/15] clean up --- .../server/lib/cloud/studio/StudioLifecycleManager.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/server/lib/cloud/studio/StudioLifecycleManager.ts b/packages/server/lib/cloud/studio/StudioLifecycleManager.ts index 391b81363aa..02c4cddc70a 100644 --- a/packages/server/lib/cloud/studio/StudioLifecycleManager.ts +++ b/packages/server/lib/cloud/studio/StudioLifecycleManager.ts @@ -204,16 +204,11 @@ export class StudioLifecycleManager { const serverFilePath = path.join(studioPath, 'server', 'index.js') const script = await readFile(serverFilePath, 'utf8') - const expectedHash = manifest[path.join('server', 'index.js')] + const actualHash = crypto.createHash('sha256').update(script).digest('hex') - // TODO: once the services have deployed, we should remove this check - if (expectedHash) { - const actualHash = crypto.createHash('sha256').update(script).digest('hex') - - if (!process.env.CYPRESS_LOCAL_STUDIO_PATH && actualHash !== expectedHash) { - throw new Error('Invalid hash for studio server script') - } + if (!process.env.CYPRESS_LOCAL_STUDIO_PATH && actualHash !== expectedHash) { + throw new Error('Invalid hash for studio server script') } const studioManager = new StudioManager() From 4d04c96ac72682c421f892950ae6f76d7a7261e6 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Mon, 30 Jun 2025 17:57:48 -0500 Subject: [PATCH 10/15] refactor --- .../cloud/studio/StudioLifecycleManager.ts | 15 +++++-- .../lib/cloud/studio/ensure_studio_bundle.ts | 2 +- .../studio/StudioLifecycleManager_spec.ts | 45 +++++++++++++++++++ 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/packages/server/lib/cloud/studio/StudioLifecycleManager.ts b/packages/server/lib/cloud/studio/StudioLifecycleManager.ts index 02c4cddc70a..cfc69dc052b 100644 --- a/packages/server/lib/cloud/studio/StudioLifecycleManager.ts +++ b/packages/server/lib/cloud/studio/StudioLifecycleManager.ts @@ -204,11 +204,18 @@ export class StudioLifecycleManager { const serverFilePath = path.join(studioPath, 'server', 'index.js') const script = await readFile(serverFilePath, 'utf8') - const expectedHash = manifest[path.join('server', 'index.js')] - const actualHash = crypto.createHash('sha256').update(script).digest('hex') - if (!process.env.CYPRESS_LOCAL_STUDIO_PATH && actualHash !== expectedHash) { - throw new Error('Invalid hash for studio server script') + if (!process.env.CYPRESS_LOCAL_STUDIO_PATH) { + const expectedHash = manifest[path.join('server', 'index.js')] + const actualHash = crypto.createHash('sha256').update(script).digest('hex') + + if (!actualHash) { + throw new Error('Studio server script not found in manifest') + } + + if (actualHash !== expectedHash) { + throw new Error('Invalid hash for studio server script') + } } const studioManager = new StudioManager() diff --git a/packages/server/lib/cloud/studio/ensure_studio_bundle.ts b/packages/server/lib/cloud/studio/ensure_studio_bundle.ts index 78a62dea5a0..38e3e4c7b99 100644 --- a/packages/server/lib/cloud/studio/ensure_studio_bundle.ts +++ b/packages/server/lib/cloud/studio/ensure_studio_bundle.ts @@ -36,7 +36,7 @@ export const ensureStudioBundle = async ({ let timeoutId: NodeJS.Timeout - const responseManifestSignature = await Promise.race([ + const responseManifestSignature: string = await Promise.race([ getStudioBundle({ studioUrl, bundlePath, diff --git a/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts b/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts index 6b97a1b204b..62761cb8109 100644 --- a/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts +++ b/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts @@ -489,6 +489,51 @@ describe('StudioLifecycleManager', () => { expect(await mockStudioManagerPromise).to.equal(updatedStudioManager) }) + it('throws an error when the studio server script is not found in the manifest', async () => { + studioManagerSetupStub.callsFake((args) => { + mockStudioManager.status = 'ENABLED' + + return Promise.resolve() + }) + + const reportErrorPromise = new Promise((resolve) => { + reportStudioErrorStub.callsFake((err) => { + resolve() + + return undefined + }) + }) + + const mockManifest = {} + + ensureStudioBundleStub.resolves(mockManifest) + + studioLifecycleManager.initializeStudioManager({ + projectId: 'test-project-id', + cloudDataSource: mockCloudDataSource, + ctx: mockCtx, + cfg: mockCfg, + debugData: {}, + }) + + await reportErrorPromise + + // @ts-expect-error - accessing private property + const studioPromise = studioLifecycleManager.studioManagerPromise + + expect(studioPromise).to.not.be.null + + expect(reportStudioErrorStub).to.be.calledOnce + expect(reportStudioErrorStub).to.be.calledWithMatch({ + cloudApi: sinon.match.object, + studioHash: 'test-project-id', + projectSlug: 'abc123', + error: sinon.match.instanceOf(Error).and(sinon.match.has('message', 'Invalid hash for studio server script')), + studioMethod: 'initializeStudioManager', + studioMethodArgs: [], + }) + }) + it('handles errors when initializing the studio manager and reports them', async () => { const error = new Error('Test error') const listener1 = sinon.stub() From eac22320189840ca6bf337da49966c270e69e3b5 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Mon, 30 Jun 2025 18:05:15 -0500 Subject: [PATCH 11/15] Update packages/server/lib/cloud/studio/StudioLifecycleManager.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/server/lib/cloud/studio/StudioLifecycleManager.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/server/lib/cloud/studio/StudioLifecycleManager.ts b/packages/server/lib/cloud/studio/StudioLifecycleManager.ts index cfc69dc052b..f176efe9864 100644 --- a/packages/server/lib/cloud/studio/StudioLifecycleManager.ts +++ b/packages/server/lib/cloud/studio/StudioLifecycleManager.ts @@ -209,8 +209,8 @@ export class StudioLifecycleManager { const expectedHash = manifest[path.join('server', 'index.js')] const actualHash = crypto.createHash('sha256').update(script).digest('hex') - if (!actualHash) { - throw new Error('Studio server script not found in manifest') + if (!expectedHash) { + throw new Error('Expected hash for studio server script not found in manifest') } if (actualHash !== expectedHash) { From 0af40b89411d1162e376300c1bd1e09823080efc Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Mon, 30 Jun 2025 18:10:13 -0500 Subject: [PATCH 12/15] refactor --- packages/server/lib/cloud/studio/StudioLifecycleManager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/lib/cloud/studio/StudioLifecycleManager.ts b/packages/server/lib/cloud/studio/StudioLifecycleManager.ts index f176efe9864..bc8373c701a 100644 --- a/packages/server/lib/cloud/studio/StudioLifecycleManager.ts +++ b/packages/server/lib/cloud/studio/StudioLifecycleManager.ts @@ -206,7 +206,7 @@ export class StudioLifecycleManager { const script = await readFile(serverFilePath, 'utf8') if (!process.env.CYPRESS_LOCAL_STUDIO_PATH) { - const expectedHash = manifest[path.join('server', 'index.js')] + const expectedHash = manifest[path.posix.join('server', 'index.js')] const actualHash = crypto.createHash('sha256').update(script).digest('hex') if (!expectedHash) { From f56e7dc29a4d7f94128add521319b6df952260a3 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Tue, 1 Jul 2025 09:26:22 -0500 Subject: [PATCH 13/15] just use the string --- packages/server/lib/cloud/studio/StudioLifecycleManager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/lib/cloud/studio/StudioLifecycleManager.ts b/packages/server/lib/cloud/studio/StudioLifecycleManager.ts index bc8373c701a..69045097276 100644 --- a/packages/server/lib/cloud/studio/StudioLifecycleManager.ts +++ b/packages/server/lib/cloud/studio/StudioLifecycleManager.ts @@ -206,7 +206,7 @@ export class StudioLifecycleManager { const script = await readFile(serverFilePath, 'utf8') if (!process.env.CYPRESS_LOCAL_STUDIO_PATH) { - const expectedHash = manifest[path.posix.join('server', 'index.js')] + const expectedHash = manifest['server/index.js'] const actualHash = crypto.createHash('sha256').update(script).digest('hex') if (!expectedHash) { From 760916147f8312b737a4800ca07fd492659538fa Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Tue, 1 Jul 2025 19:49:17 -0500 Subject: [PATCH 14/15] try and fix test --- .../test/unit/cloud/studio/StudioLifecycleManager_spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts b/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts index 62761cb8109..1bad461649c 100644 --- a/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts +++ b/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts @@ -528,7 +528,7 @@ describe('StudioLifecycleManager', () => { cloudApi: sinon.match.object, studioHash: 'test-project-id', projectSlug: 'abc123', - error: sinon.match.instanceOf(Error).and(sinon.match.has('message', 'Invalid hash for studio server script')), + error: sinon.match.instanceOf(Error).and(sinon.match.has('message', 'Expected hash for studio server script not found in manifest')), studioMethod: 'initializeStudioManager', studioMethodArgs: [], }) From 56e41cd9a64ee3bbc55232e043c0d94b160a2b90 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Tue, 1 Jul 2025 19:50:44 -0500 Subject: [PATCH 15/15] try and fix test --- .../studio/StudioLifecycleManager_spec.ts | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts b/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts index 1bad461649c..c8021145657 100644 --- a/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts +++ b/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts @@ -534,6 +534,53 @@ describe('StudioLifecycleManager', () => { }) }) + it('throws an error when the studio server script is wrong in the manifest', async () => { + studioManagerSetupStub.callsFake((args) => { + mockStudioManager.status = 'ENABLED' + + return Promise.resolve() + }) + + const reportErrorPromise = new Promise((resolve) => { + reportStudioErrorStub.callsFake((err) => { + resolve() + + return undefined + }) + }) + + const mockManifest = { + 'server/index.js': 'a1', + } + + ensureStudioBundleStub.resolves(mockManifest) + + studioLifecycleManager.initializeStudioManager({ + projectId: 'test-project-id', + cloudDataSource: mockCloudDataSource, + ctx: mockCtx, + cfg: mockCfg, + debugData: {}, + }) + + await reportErrorPromise + + // @ts-expect-error - accessing private property + const studioPromise = studioLifecycleManager.studioManagerPromise + + expect(studioPromise).to.not.be.null + + expect(reportStudioErrorStub).to.be.calledOnce + expect(reportStudioErrorStub).to.be.calledWithMatch({ + cloudApi: sinon.match.object, + studioHash: 'test-project-id', + projectSlug: 'abc123', + error: sinon.match.instanceOf(Error).and(sinon.match.has('message', 'Invalid hash for studio server script')), + studioMethod: 'initializeStudioManager', + studioMethodArgs: [], + }) + }) + it('handles errors when initializing the studio manager and reports them', async () => { const error = new Error('Test error') const listener1 = sinon.stub()