From 4da09bc4ece6cc1543fb1a9600a18a59eb9e4b96 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Sat, 21 Jun 2025 15:48:29 -0500 Subject: [PATCH 01/11] chore: (cy.prompt) add manifest for all of the cloud delivered files --- .../api/cy-prompt/get_cy_prompt_bundle.ts | 8 +++- .../cy-prompt/CyPromptLifecycleManager.ts | 13 ++++++- .../lib/cloud/cy-prompt/CyPromptManager.ts | 14 ++++++- .../cy-prompt/ensure_cy_prompt_bundle.ts | 8 ++-- packages/server/lib/cloud/encryption.ts | 4 +- .../cy-prompt/get_cy_prompt_bundle_spec.ts | 20 +++++++++- .../CyPromptLifecycleManager_spec.ts | 38 ++++++++++++++++++- .../cloud/cy-prompt/CyPromptManager_spec.ts | 4 ++ .../cy-prompt/ensure_cy_prompt_bundle_spec.ts | 9 ++++- .../src/cy-prompt/cy-prompt-server-types.ts | 3 ++ 10 files changed, 106 insertions(+), 15 deletions(-) diff --git a/packages/server/lib/cloud/api/cy-prompt/get_cy_prompt_bundle.ts b/packages/server/lib/cloud/api/cy-prompt/get_cy_prompt_bundle.ts index 0eb3f3879af..f10b666046d 100644 --- a/packages/server/lib/cloud/api/cy-prompt/get_cy_prompt_bundle.ts +++ b/packages/server/lib/cloud/api/cy-prompt/get_cy_prompt_bundle.ts @@ -10,10 +10,10 @@ import { verifySignatureFromFile } from '../../encryption' const pkg = require('@packages/root') const _delay = linearDelay(500) -export const getCyPromptBundle = async ({ cyPromptUrl, projectId, bundlePath }: { cyPromptUrl: string, projectId?: string, bundlePath: string }) => { +export const getCyPromptBundle = async ({ cyPromptUrl, projectId, bundlePath }: { cyPromptUrl: string, projectId?: string, bundlePath: string }): Promise> => { let responseSignature: string | null = null - await (asyncRetry(async () => { + const manifest = await (asyncRetry(async () => { const response = await fetch(cyPromptUrl, { // @ts-expect-error - this is supported agent, @@ -46,6 +46,8 @@ export const getCyPromptBundle = async ({ cyPromptUrl, projectId, bundlePath }: // @ts-expect-error - this is supported response.body?.pipe(writeStream) }) + + return JSON.parse(response.headers.get('x-cypress-manifest') || '{}') }, { maxAttempts: 3, retryDelay: _delay, @@ -61,4 +63,6 @@ export const getCyPromptBundle = async ({ cyPromptUrl, projectId, bundlePath }: if (!verified) { throw new Error('Unable to verify cy-prompt signature') } + + return manifest } diff --git a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts index 2809e815d7f..9b5073bb951 100644 --- a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts +++ b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts @@ -13,11 +13,12 @@ import { ensureCyPromptBundle } from './ensure_cy_prompt_bundle' import chokidar from 'chokidar' import { getCloudMetadata } from '../get_cloud_metadata' import type { CyPromptAuthenticatedUserShape } from '@packages/types' +import { verifySignature } from '../encryption' const debug = Debug('cypress:server:cy-prompt-lifecycle-manager') export class CyPromptLifecycleManager { - private static hashLoadingMap: Map> = new Map() + private static hashLoadingMap: Map>> = new Map() private static watcher: chokidar.FSWatcher | null = null private cyPromptManagerPromise?: Promise<{ cyPromptManager?: CyPromptManager @@ -124,6 +125,7 @@ export class CyPromptLifecycleManager { }): Promise<{ cyPromptManager?: CyPromptManager, error?: Error }> { let cyPromptHash: string let cyPromptPath: string + let manifest: Record const currentProjectOptions = await getProjectOptions() const projectId = currentProjectOptions.projectSlug @@ -148,15 +150,21 @@ export class CyPromptLifecycleManager { CyPromptLifecycleManager.hashLoadingMap.set(cyPromptHash, hashLoadingPromise) } - await hashLoadingPromise + manifest = await hashLoadingPromise } else { cyPromptPath = process.env.CYPRESS_LOCAL_CY_PROMPT_PATH cyPromptHash = 'local' + manifest = {} } const serverFilePath = path.join(cyPromptPath, 'server', 'index.js') const script = await readFile(serverFilePath, 'utf8') + + if (!process.env.CYPRESS_LOCAL_CY_PROMPT_PATH && !verifySignature(script, manifest[path.join('server', 'index.js')])) { + throw new Error('Invalid signature for cy prompt server script') + } + const cyPromptManager = new CyPromptManager() const { cloudUrl } = await getCloudMetadata(cloudDataSource) @@ -172,6 +180,7 @@ export class CyPromptLifecycleManager { asyncRetry, }, getProjectOptions, + manifest, }) debug('cy prompt is ready') diff --git a/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts index 3aedaf852c1..40f7e51906b 100644 --- a/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts +++ b/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts @@ -3,6 +3,7 @@ import type { Router } from 'express' import Debug from 'debug' import { requireScript } from '../require_script' import type { Socket } from 'socket.io' +import { verifySignature } from '../encryption' interface CyPromptServer { default: CyPromptServerDefaultShape } @@ -18,6 +19,7 @@ interface SetupOptions { record?: boolean key?: string }> + manifest: Record } const debug = Debug('cypress:server:cy-prompt') @@ -26,7 +28,7 @@ export class CyPromptManager implements CyPromptManagerShape { status: CyPromptStatus = 'NOT_INITIALIZED' private _cyPromptServer: CyPromptServerShape | undefined - async setup ({ script, cyPromptPath, cyPromptHash, getProjectOptions, cloudApi }: SetupOptions): Promise { + async setup ({ script, cyPromptPath, cyPromptHash, getProjectOptions, cloudApi, manifest }: SetupOptions): Promise { const { createCyPromptServer } = requireScript(script).default this._cyPromptServer = await createCyPromptServer({ @@ -34,6 +36,16 @@ export class CyPromptManager implements CyPromptManagerShape { cyPromptPath, cloudApi, getProjectOptions, + 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_CY_PROMPT_PATH) { + return true + } + + return verifySignature(script, signature) + }, }) this.status = 'INITIALIZED' diff --git a/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts b/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts index 8691fbea863..068ee826cf4 100644 --- a/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts +++ b/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts @@ -21,7 +21,7 @@ interface EnsureCyPromptBundleOptions { * @param options.projectId - The project ID of the cy prompt bundle * @param options.downloadTimeoutMs - The timeout for the cy prompt bundle download */ -export const ensureCyPromptBundle = async ({ cyPromptPath, cyPromptUrl, projectId, downloadTimeoutMs = DOWNLOAD_TIMEOUT }: EnsureCyPromptBundleOptions) => { +export const ensureCyPromptBundle = async ({ cyPromptPath, cyPromptUrl, projectId, downloadTimeoutMs = DOWNLOAD_TIMEOUT }: EnsureCyPromptBundleOptions): Promise> => { const bundlePath = path.join(cyPromptPath, 'bundle.tar') // First remove cyPromptPath to ensure we have a clean slate @@ -30,7 +30,7 @@ export const ensureCyPromptBundle = async ({ cyPromptPath, cyPromptUrl, projectI let timeoutId: NodeJS.Timeout - await Promise.race([ + const manifest = await Promise.race([ getCyPromptBundle({ cyPromptUrl, projectId, @@ -43,10 +43,12 @@ export const ensureCyPromptBundle = async ({ cyPromptPath, cyPromptUrl, projectI }), ]).finally(() => { clearTimeout(timeoutId) - }) + }) as Promise> await tar.extract({ file: bundlePath, cwd: cyPromptPath, }) + + 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/test/unit/cloud/api/cy-prompt/get_cy_prompt_bundle_spec.ts b/packages/server/test/unit/cloud/api/cy-prompt/get_cy_prompt_bundle_spec.ts index 35fe19437b0..d05ac2e2d23 100644 --- a/packages/server/test/unit/cloud/api/cy-prompt/get_cy_prompt_bundle_spec.ts +++ b/packages/server/test/unit/cloud/api/cy-prompt/get_cy_prompt_bundle_spec.ts @@ -44,6 +44,8 @@ describe('getCyPromptBundle', () => { }) it('downloads the cy-prompt bundle and extracts it', async () => { + const mockManifest = { 'app/cy-prompt.js': 'abcdefg' } + crossFetchStub.resolves({ ok: true, statusText: 'OK', @@ -53,6 +55,10 @@ describe('getCyPromptBundle', () => { if (header === 'x-cypress-signature') { return '159' } + + if (header === 'x-cypress-manifest') { + return JSON.stringify(mockManifest) + } }, }, }) @@ -61,7 +67,7 @@ describe('getCyPromptBundle', () => { const projectId = '12345' - await getCyPromptBundle({ cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/cy-prompt/abc/bundle.tar' }) + const manifest = await getCyPromptBundle({ cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/cy-prompt/abc/bundle.tar' }) expect(crossFetchStub).to.be.calledWith('http://localhost:1234/cy-prompt/bundle/abc.tgz', { agent: sinon.match.any, @@ -80,9 +86,13 @@ describe('getCyPromptBundle', () => { expect(writeResult).to.eq('console.log("cy-prompt script")') expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/cy-prompt/abc/bundle.tar', '159') + + expect(manifest).to.deep.eq(mockManifest) }) it('downloads the cy-prompt bundle and extracts it after 1 fetch failure', async () => { + const mockManifest = { 'app/cy-prompt.js': 'abcdefg' } + crossFetchStub.onFirstCall().rejects(new HttpError('Failed to fetch', 'url', 502, 'Bad Gateway', 'Bad Gateway', sinon.stub())) crossFetchStub.onSecondCall().resolves({ ok: true, @@ -93,6 +103,10 @@ describe('getCyPromptBundle', () => { if (header === 'x-cypress-signature') { return '159' } + + if (header === 'x-cypress-manifest') { + return JSON.stringify(mockManifest) + } }, }, }) @@ -101,7 +115,7 @@ describe('getCyPromptBundle', () => { const projectId = '12345' - await getCyPromptBundle({ cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/cy-prompt/abc/bundle.tar' }) + const manifest = await getCyPromptBundle({ cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/cy-prompt/abc/bundle.tar' }) expect(crossFetchStub).to.be.calledWith('http://localhost:1234/cy-prompt/bundle/abc.tgz', { agent: sinon.match.any, @@ -120,6 +134,8 @@ describe('getCyPromptBundle', () => { expect(writeResult).to.eq('console.log("cy-prompt script")') expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/cy-prompt/abc/bundle.tar', '159') + + expect(manifest).to.deep.eq(mockManifest) }) it('throws an error and returns a cy-prompt manager in error state if the fetch fails more than twice', async () => { diff --git a/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts b/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts index 091d9fd0081..12044adbce7 100644 --- a/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts +++ b/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts @@ -24,12 +24,15 @@ describe('CyPromptLifecycleManager', () => { let watcherStub: sinon.SinonStub = sinon.stub() let watcherOnStub: sinon.SinonStub = sinon.stub() let watcherCloseStub: sinon.SinonStub = sinon.stub() + let verifySignatureStub: sinon.SinonStub = sinon.stub() + const mockContents: string = 'console.log("cy-prompt script")' beforeEach(() => { postCyPromptSessionStub = sinon.stub() cyPromptManagerSetupStub = sinon.stub() ensureCyPromptBundleStub = sinon.stub() cyPromptStatusChangeEmitterStub = sinon.stub() + verifySignatureStub = sinon.stub() mockCyPromptManager = { status: 'INITIALIZED', setup: cyPromptManagerSetupStub.resolves(), @@ -51,7 +54,7 @@ describe('CyPromptLifecycleManager', () => { }, }, 'fs-extra': { - readFile: readFileStub.resolves('console.log("cy-prompt script")'), + readFile: readFileStub.resolves(mockContents), }, 'chokidar': { watch: watcherStub.returns({ @@ -59,6 +62,9 @@ describe('CyPromptLifecycleManager', () => { close: watcherCloseStub, }), }, + '../encryption': { + verifySignature: verifySignatureStub, + }, }).CyPromptLifecycleManager cyPromptLifecycleManager = new CyPromptLifecycleManager() @@ -122,6 +128,13 @@ describe('CyPromptLifecycleManager', () => { }) }) + const mockManifest = { + 'server/index.js': 'abcdefg', + } + + ensureCyPromptBundleStub.resolves(mockManifest) + verifySignatureStub.returns(true) + await cyPromptReadyPromise expect(mockCtx.update).to.be.calledOnce @@ -142,12 +155,15 @@ describe('CyPromptLifecycleManager', () => { asyncRetry, }, getProjectOptions: sinon.match.func, + manifest: mockManifest, }) expect(postCyPromptSessionStub).to.be.calledWith({ projectId: 'test-project-id', }) + expect(verifySignatureStub).to.be.calledWith(mockContents, 'abcdefg') + expect(mockCloudDataSource.getCloudUrl).to.be.calledWith('test') expect(mockCloudDataSource.additionalHeaders).to.be.called expect(readFileStub).to.be.calledWith(path.join(os.tmpdir(), 'cypress', 'cy-prompt', 'abc', 'server', 'index.js'), 'utf8') @@ -167,6 +183,13 @@ describe('CyPromptLifecycleManager', () => { }) }) + const mockManifest = { + 'server/index.js': 'abcdefg', + } + + ensureCyPromptBundleStub.resolves(mockManifest) + verifySignatureStub.returns(true) + const cyPromptManager1 = await cyPromptReadyPromise1 cyPromptLifecycleManager.initializeCyPromptManager({ @@ -205,12 +228,15 @@ describe('CyPromptLifecycleManager', () => { asyncRetry, }, getProjectOptions: sinon.match.func, + manifest: mockManifest, }) expect(postCyPromptSessionStub).to.be.calledWith({ projectId: 'test-project-id', }) + expect(verifySignatureStub).to.be.calledWith(mockContents, 'abcdefg') + expect(mockCloudDataSource.getCloudUrl).to.be.calledWith('test') expect(mockCloudDataSource.additionalHeaders).to.be.called expect(readFileStub).to.be.calledWith(path.join(os.tmpdir(), 'cypress', 'cy-prompt', 'abc', 'server', 'index.js'), 'utf8') @@ -248,6 +274,7 @@ describe('CyPromptLifecycleManager', () => { asyncRetry, }, getProjectOptions: sinon.match.func, + manifest: {}, }) expect(postCyPromptSessionStub).to.be.calledWith({ @@ -304,6 +331,15 @@ describe('CyPromptLifecycleManager', () => { }) describe('registerCyPromptReadyListener', () => { + beforeEach(() => { + const mockManifest = { + 'server/index.js': 'abcdefg', + } + + ensureCyPromptBundleStub.resolves(mockManifest) + verifySignatureStub.returns(true) + }) + it('registers a listener that will be called when cy-prompt is ready', () => { const listener = sinon.stub() diff --git a/packages/server/test/unit/cloud/cy-prompt/CyPromptManager_spec.ts b/packages/server/test/unit/cloud/cy-prompt/CyPromptManager_spec.ts index a4009743292..5634538eabf 100644 --- a/packages/server/test/unit/cloud/cy-prompt/CyPromptManager_spec.ts +++ b/packages/server/test/unit/cloud/cy-prompt/CyPromptManager_spec.ts @@ -31,6 +31,10 @@ describe('lib/cloud/cy-prompt', () => { cyPromptHash: 'abcdefg', projectSlug: '1234', cloudApi: {} as any, + manifest: { + 'server/index.js': 'abcdefg', + }, + getProjectOptions: {} as any, }) cyPrompt = (cyPromptManager as any)._cyPromptServer diff --git a/packages/server/test/unit/cloud/cy-prompt/ensure_cy_prompt_bundle_spec.ts b/packages/server/test/unit/cloud/cy-prompt/ensure_cy_prompt_bundle_spec.ts index cba1100c9df..07dad41f02c 100644 --- a/packages/server/test/unit/cloud/cy-prompt/ensure_cy_prompt_bundle_spec.ts +++ b/packages/server/test/unit/cloud/cy-prompt/ensure_cy_prompt_bundle_spec.ts @@ -9,6 +9,9 @@ describe('ensureCyPromptBundle', () => { let ensureStub: sinon.SinonStub = sinon.stub() let extractStub: sinon.SinonStub = sinon.stub() let getCyPromptBundleStub: sinon.SinonStub = sinon.stub() + const mockManifest = { + 'server/index.js': 'abcdefg', + } beforeEach(() => { rmStub = sinon.stub() @@ -29,7 +32,7 @@ describe('ensureCyPromptBundle', () => { extract: extractStub.resolves(), }, '../api/cy-prompt/get_cy_prompt_bundle': { - getCyPromptBundle: getCyPromptBundleStub.resolves(), + getCyPromptBundle: getCyPromptBundleStub.resolves(mockManifest), }, })).ensureCyPromptBundle }) @@ -38,7 +41,7 @@ describe('ensureCyPromptBundle', () => { const cyPromptPath = path.join(os.tmpdir(), 'cypress', 'cy-prompt', '123') const bundlePath = path.join(cyPromptPath, 'bundle.tar') - await ensureCyPromptBundle({ + const manifest = await ensureCyPromptBundle({ cyPromptPath, cyPromptUrl: 'https://cypress.io/cy-prompt', projectId: '123', @@ -56,6 +59,8 @@ describe('ensureCyPromptBundle', () => { file: bundlePath, cwd: cyPromptPath, }) + + expect(manifest).to.deep.eq(mockManifest) }) it('should throw an error if the cy prompt bundle download times out', async () => { diff --git a/packages/types/src/cy-prompt/cy-prompt-server-types.ts b/packages/types/src/cy-prompt/cy-prompt-server-types.ts index 2d882ec5361..79559fa466d 100644 --- a/packages/types/src/cy-prompt/cy-prompt-server-types.ts +++ b/packages/types/src/cy-prompt/cy-prompt-server-types.ts @@ -4,6 +4,7 @@ import type ProtocolMapping from 'devtools-protocol/types/protocol-mapping.d' import type { Router } from 'express' import type { AxiosInstance } from 'axios' import type { Socket } from 'socket.io' +import type { BinaryLike } from 'crypto' export type CyPromptCommands = ProtocolMapping.Commands @@ -49,6 +50,8 @@ export interface CyPromptServerOptions { }> cyPromptPath: string cloudApi: CyPromptCloudApi + manifest: Record + verifySignature: (script: BinaryLike, signature: string) => boolean } export interface CyPromptCDPClient { From b1a192f3a4f02c458eb741b96329b99e1aa114a7 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Sat, 21 Jun 2025 21:25:52 -0500 Subject: [PATCH 02/11] fix tests and remove environment variables --- .../cy-prompt/CyPromptLifecycleManager.ts | 9 +++++++-- scripts/after-pack-hook.js | 12 ++++++++++++ scripts/binary/binary-sources.js | 18 ++++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts index 9b5073bb951..1c6bd7e61fd 100644 --- a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts +++ b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts @@ -161,8 +161,13 @@ export class CyPromptLifecycleManager { const script = await readFile(serverFilePath, 'utf8') - if (!process.env.CYPRESS_LOCAL_CY_PROMPT_PATH && !verifySignature(script, manifest[path.join('server', 'index.js')])) { - throw new Error('Invalid signature for cy prompt 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_CY_PROMPT_PATH && !verifySignature(script, signature)) { + throw new Error('Invalid signature for cy prompt server script') + } } const cyPromptManager = new CyPromptManager() diff --git a/scripts/after-pack-hook.js b/scripts/after-pack-hook.js index ea0e365c283..562ba2904fc 100644 --- a/scripts/after-pack-hook.js +++ b/scripts/after-pack-hook.js @@ -19,6 +19,8 @@ const { getStudioFileSource, validateStudioFile, getIndexJscHash, + getCyPromptFileSource, + validateCyPromptFile, DUMMY_INDEX_JSC_HASH, } = require('./binary/binary-sources') const verify = require('../cli/lib/tasks/verify') @@ -100,6 +102,12 @@ module.exports = async function (params) { const StudioLifecycleManagerPath = path.join(CY_ROOT_DIR, 'packages/server/lib/cloud/studio/StudioLifecycleManager.ts') const StudioLifecycleManagerFileSource = await getStudioFileSource(StudioLifecycleManagerPath) + // Remove local cy prompt env + const cyPromptLifecycleManagerPath = path.join(CY_ROOT_DIR, 'packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts') + const cyPromptLifecycleManagerFileSource = await getCyPromptFileSource(cyPromptLifecycleManagerPath) + const cyPromptManagerPath = path.join(CY_ROOT_DIR, 'packages/server/lib/cloud/cy-prompt/CyPromptManager.ts') + const cyPromptManagerFileSource = await getCyPromptFileSource(cyPromptManagerPath) + await Promise.all([ fs.writeFile(encryptionFilePath, encryptionFileSource), fs.writeFile(cloudEnvironmentFilePath, cloudEnvironmentFileSource), @@ -107,6 +115,8 @@ module.exports = async function (params) { fs.writeFile(cloudProtocolFilePath, cloudProtocolFileSource), fs.writeFile(reportStudioErrorPath, reportStudioErrorFileSource), fs.writeFile(StudioLifecycleManagerPath, StudioLifecycleManagerFileSource), + fs.writeFile(cyPromptLifecycleManagerPath, cyPromptLifecycleManagerFileSource), + fs.writeFile(cyPromptManagerPath, cyPromptManagerFileSource), fs.writeFile(path.join(outputFolder, 'index.js'), binaryEntryPointSource), ]) @@ -121,6 +131,8 @@ module.exports = async function (params) { validateProtocolFile(cloudProtocolFilePath), validateStudioFile(reportStudioErrorPath), validateStudioFile(StudioLifecycleManagerPath), + validateCyPromptFile(cyPromptLifecycleManagerPath), + validateCyPromptFile(cyPromptManagerPath), ]) await flipFuses( diff --git a/scripts/binary/binary-sources.js b/scripts/binary/binary-sources.js index a6196eea094..7e5d0efad46 100644 --- a/scripts/binary/binary-sources.js +++ b/scripts/binary/binary-sources.js @@ -119,6 +119,14 @@ const getStudioFileSource = async (studioFilePath) => { return fileContents.replaceAll('process.env.CYPRESS_LOCAL_STUDIO_PATH', 'undefined') } +const getCyPromptFileSource = async (cyPromptFilePath) => { + const fileContents = await fs.readFile(cyPromptFilePath, 'utf8') + + if (!fileContents.includes('process.env.CYPRESS_LOCAL_CY_PROMPT_PATH')) { + throw new Error(`Expected to find CYPRESS_LOCAL_CY_PROMPT_PATH in cy prompt file`) + } +} + const validateProtocolFile = async (protocolFilePath) => { const afterReplaceProtocol = await fs.readFile(protocolFilePath, 'utf8') @@ -135,6 +143,14 @@ const validateStudioFile = async (studioFilePath) => { } } +const validateCyPromptFile = async (cyPromptFilePath) => { + const afterReplaceCyPrompt = await fs.readFile(cyPromptFilePath, 'utf8') + + if (afterReplaceCyPrompt.includes('process.env.CYPRESS_LOCAL_CY_PROMPT_PATH')) { + throw new Error(`Expected process.env.CYPRESS_LOCAL_CY_PROMPT_PATH to be stripped from cy prompt file`) + } +} + module.exports = { getBinaryEntryPointSource, getBinaryByteNodeEntryPointSource, @@ -147,6 +163,8 @@ module.exports = { validateProtocolFile, getStudioFileSource, validateStudioFile, + getCyPromptFileSource, + validateCyPromptFile, getIndexJscHash, DUMMY_INDEX_JSC_HASH, } From d78fcb3248593e81dcb4a4675c828db3bbaa00a5 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Sat, 21 Jun 2025 23:34:51 -0500 Subject: [PATCH 03/11] update strategy --- .../api/cy-prompt/get_cy_prompt_bundle.ts | 15 +--- .../cy-prompt/CyPromptLifecycleManager.ts | 14 ++-- .../lib/cloud/cy-prompt/CyPromptManager.ts | 8 +- .../cy-prompt/ensure_cy_prompt_bundle.ts | 18 ++++- .../cy-prompt/get_cy_prompt_bundle_spec.ts | 74 +------------------ .../CyPromptLifecycleManager_spec.ts | 18 +---- .../cy-prompt/ensure_cy_prompt_bundle_spec.ts | 26 ++++++- .../src/cy-prompt/cy-prompt-server-types.ts | 2 +- 8 files changed, 62 insertions(+), 113 deletions(-) diff --git a/packages/server/lib/cloud/api/cy-prompt/get_cy_prompt_bundle.ts b/packages/server/lib/cloud/api/cy-prompt/get_cy_prompt_bundle.ts index f10b666046d..901e52af196 100644 --- a/packages/server/lib/cloud/api/cy-prompt/get_cy_prompt_bundle.ts +++ b/packages/server/lib/cloud/api/cy-prompt/get_cy_prompt_bundle.ts @@ -5,15 +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 getCyPromptBundle = async ({ cyPromptUrl, projectId, bundlePath }: { cyPromptUrl: string, projectId?: string, bundlePath: string }): Promise> => { +export const getCyPromptBundle = async ({ cyPromptUrl, projectId, bundlePath }: { cyPromptUrl: string, projectId?: string, bundlePath: string }): Promise => { let responseSignature: string | null = null - const manifest = await (asyncRetry(async () => { + await (asyncRetry(async () => { const response = await fetch(cyPromptUrl, { // @ts-expect-error - this is supported agent, @@ -46,8 +45,6 @@ export const getCyPromptBundle = async ({ cyPromptUrl, projectId, bundlePath }: // @ts-expect-error - this is supported response.body?.pipe(writeStream) }) - - return JSON.parse(response.headers.get('x-cypress-manifest') || '{}') }, { maxAttempts: 3, retryDelay: _delay, @@ -58,11 +55,5 @@ export const getCyPromptBundle = async ({ cyPromptUrl, projectId, bundlePath }: throw new Error('Unable to get cy-prompt signature') } - const verified = await verifySignatureFromFile(bundlePath, responseSignature) - - if (!verified) { - throw new Error('Unable to verify cy-prompt signature') - } - - return manifest + return responseSignature } diff --git a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts index 1c6bd7e61fd..bd0e1550239 100644 --- a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts +++ b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts @@ -13,8 +13,7 @@ import { ensureCyPromptBundle } from './ensure_cy_prompt_bundle' import chokidar from 'chokidar' import { getCloudMetadata } from '../get_cloud_metadata' import type { CyPromptAuthenticatedUserShape } from '@packages/types' -import { verifySignature } from '../encryption' - +import crypto from 'crypto' const debug = Debug('cypress:server:cy-prompt-lifecycle-manager') export class CyPromptLifecycleManager { @@ -160,13 +159,14 @@ export class CyPromptLifecycleManager { const serverFilePath = path.join(cyPromptPath, 'server', 'index.js') 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_CY_PROMPT_PATH && !verifySignature(script, signature)) { - throw new Error('Invalid signature for cy prompt server script') + if (expectedHash) { + const actualHash = crypto.createHash('sha256').update(script).digest('hex') + + if (!process.env.CYPRESS_LOCAL_CY_PROMPT_PATH && actualHash !== expectedHash) { + throw new Error('Invalid hash for cy prompt server script') } } diff --git a/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts index 9612d821577..89d786275bb 100644 --- a/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts +++ b/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts @@ -3,7 +3,7 @@ import type { Router } from 'express' import Debug from 'debug' import { requireScript } from '../require_script' import type { Socket } from 'socket.io' -import { verifySignature } from '../encryption' +import crypto, { BinaryLike } from 'crypto' interface CyPromptServer { default: CyPromptServerDefaultShape } @@ -37,14 +37,16 @@ export class CyPromptManager implements CyPromptManagerShape { cloudApi, getProjectOptions, 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_CY_PROMPT_PATH) { return true } - return verifySignature(script, signature) + const actualHash = crypto.createHash('sha256').update(contents).digest('hex') + + return actualHash === expectedHash }, }) diff --git a/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts b/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts index 068ee826cf4..339d40d8fd2 100644 --- a/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts +++ b/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts @@ -1,8 +1,9 @@ -import { remove, ensureDir } from 'fs-extra' +import { remove, ensureDir, readFile } from 'fs-extra' import tar from 'tar' import { getCyPromptBundle } from '../api/cy-prompt/get_cy_prompt_bundle' import path from 'path' +import { verifySignature } from '../encryption' const DOWNLOAD_TIMEOUT = 30000 @@ -30,7 +31,7 @@ export const ensureCyPromptBundle = async ({ cyPromptPath, cyPromptUrl, projectI let timeoutId: NodeJS.Timeout - const manifest = await Promise.race([ + const responseSignature = await Promise.race([ getCyPromptBundle({ cyPromptUrl, projectId, @@ -43,12 +44,21 @@ export const ensureCyPromptBundle = async ({ cyPromptPath, cyPromptUrl, projectI }), ]).finally(() => { clearTimeout(timeoutId) - }) as Promise> + }) as string await tar.extract({ file: bundlePath, cwd: cyPromptPath, }) - return manifest + const manifestPath = path.join(cyPromptPath, 'manifest.json') + const manifestContents = await readFile(manifestPath, 'utf8') + + const verified = await verifySignature(manifestContents, responseSignature) + + if (!verified) { + throw new Error('Unable to verify cy-prompt signature') + } + + return JSON.parse(manifestContents) } diff --git a/packages/server/test/unit/cloud/api/cy-prompt/get_cy_prompt_bundle_spec.ts b/packages/server/test/unit/cloud/api/cy-prompt/get_cy_prompt_bundle_spec.ts index d05ac2e2d23..e4d92c1885a 100644 --- a/packages/server/test/unit/cloud/api/cy-prompt/get_cy_prompt_bundle_spec.ts +++ b/packages/server/test/unit/cloud/api/cy-prompt/get_cy_prompt_bundle_spec.ts @@ -7,13 +7,11 @@ describe('getCyPromptBundle', () => { let readStream: Readable let createWriteStreamStub: sinon.SinonStub let crossFetchStub: sinon.SinonStub - let verifySignatureFromFileStub: sinon.SinonStub let getCyPromptBundle: typeof import('../../../../../lib/cloud/api/cy-prompt/get_cy_prompt_bundle').getCyPromptBundle beforeEach(() => { createWriteStreamStub = sinon.stub() crossFetchStub = sinon.stub() - verifySignatureFromFileStub = sinon.stub() readStream = Readable.from('console.log("cy-prompt script")') writeResult = '' @@ -31,9 +29,6 @@ describe('getCyPromptBundle', () => { createWriteStream: createWriteStreamStub, }, 'cross-fetch': crossFetchStub, - '../../encryption': { - verifySignatureFromFile: verifySignatureFromFileStub, - }, 'os': { platform: () => 'linux', }, @@ -44,8 +39,6 @@ describe('getCyPromptBundle', () => { }) it('downloads the cy-prompt bundle and extracts it', async () => { - const mockManifest = { 'app/cy-prompt.js': 'abcdefg' } - crossFetchStub.resolves({ ok: true, statusText: 'OK', @@ -55,19 +48,13 @@ describe('getCyPromptBundle', () => { if (header === 'x-cypress-signature') { return '159' } - - if (header === 'x-cypress-manifest') { - return JSON.stringify(mockManifest) - } }, }, }) - verifySignatureFromFileStub.resolves(true) - const projectId = '12345' - const manifest = await getCyPromptBundle({ cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/cy-prompt/abc/bundle.tar' }) + const responseSignature = await getCyPromptBundle({ cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/cy-prompt/abc/bundle.tar' }) expect(crossFetchStub).to.be.calledWith('http://localhost:1234/cy-prompt/bundle/abc.tgz', { agent: sinon.match.any, @@ -85,14 +72,10 @@ describe('getCyPromptBundle', () => { expect(writeResult).to.eq('console.log("cy-prompt script")') - expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/cy-prompt/abc/bundle.tar', '159') - - expect(manifest).to.deep.eq(mockManifest) + expect(responseSignature).to.eq('159') }) it('downloads the cy-prompt bundle and extracts it after 1 fetch failure', async () => { - const mockManifest = { 'app/cy-prompt.js': 'abcdefg' } - crossFetchStub.onFirstCall().rejects(new HttpError('Failed to fetch', 'url', 502, 'Bad Gateway', 'Bad Gateway', sinon.stub())) crossFetchStub.onSecondCall().resolves({ ok: true, @@ -103,19 +86,13 @@ describe('getCyPromptBundle', () => { if (header === 'x-cypress-signature') { return '159' } - - if (header === 'x-cypress-manifest') { - return JSON.stringify(mockManifest) - } }, }, }) - verifySignatureFromFileStub.resolves(true) - const projectId = '12345' - const manifest = await getCyPromptBundle({ cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/cy-prompt/abc/bundle.tar' }) + const responseSignature = await getCyPromptBundle({ cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/cy-prompt/abc/bundle.tar' }) expect(crossFetchStub).to.be.calledWith('http://localhost:1234/cy-prompt/bundle/abc.tgz', { agent: sinon.match.any, @@ -133,9 +110,7 @@ describe('getCyPromptBundle', () => { expect(writeResult).to.eq('console.log("cy-prompt script")') - expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/cy-prompt/abc/bundle.tar', '159') - - expect(manifest).to.deep.eq(mockManifest) + expect(responseSignature).to.eq('159') }) it('throws an error and returns a cy-prompt manager in error state if the fetch fails more than twice', async () => { @@ -188,47 +163,6 @@ describe('getCyPromptBundle', () => { }) }) - 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' - } - }, - }, - }) - - verifySignatureFromFileStub.resolves(false) - - const projectId = '12345' - - await expect(getCyPromptBundle({ cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/cy-prompt/abc/bundle.tar' })).to.be.rejected - - expect(writeResult).to.eq('console.log("cy-prompt script")') - - expect(crossFetchStub).to.be.calledWith('http://localhost:1234/cy-prompt/bundle/abc.tgz', { - agent: sinon.match.any, - method: 'GET', - headers: { - 'x-route-version': '1', - 'x-cypress-signature': '1', - 'x-cypress-project-slug': '12345', - 'x-cypress-cy-prompt-mount-version': '1', - 'x-os-name': 'linux', - 'x-cypress-version': '1.2.3', - }, - encrypt: 'signed', - }) - - expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/cy-prompt/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/cy-prompt/CyPromptLifecycleManager_spec.ts b/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts index 12044adbce7..e86bed83ad7 100644 --- a/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts +++ b/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts @@ -24,7 +24,6 @@ describe('CyPromptLifecycleManager', () => { let watcherStub: sinon.SinonStub = sinon.stub() let watcherOnStub: sinon.SinonStub = sinon.stub() let watcherCloseStub: sinon.SinonStub = sinon.stub() - let verifySignatureStub: sinon.SinonStub = sinon.stub() const mockContents: string = 'console.log("cy-prompt script")' beforeEach(() => { @@ -32,7 +31,6 @@ describe('CyPromptLifecycleManager', () => { cyPromptManagerSetupStub = sinon.stub() ensureCyPromptBundleStub = sinon.stub() cyPromptStatusChangeEmitterStub = sinon.stub() - verifySignatureStub = sinon.stub() mockCyPromptManager = { status: 'INITIALIZED', setup: cyPromptManagerSetupStub.resolves(), @@ -62,9 +60,6 @@ describe('CyPromptLifecycleManager', () => { close: watcherCloseStub, }), }, - '../encryption': { - verifySignature: verifySignatureStub, - }, }).CyPromptLifecycleManager cyPromptLifecycleManager = new CyPromptLifecycleManager() @@ -129,11 +124,10 @@ describe('CyPromptLifecycleManager', () => { }) const mockManifest = { - 'server/index.js': 'abcdefg', + 'server/index.js': 'c3c4ab913ca059819549f105e756a4c4471df19abef884ce85eafc7b7970e7b4', } ensureCyPromptBundleStub.resolves(mockManifest) - verifySignatureStub.returns(true) await cyPromptReadyPromise @@ -162,8 +156,6 @@ describe('CyPromptLifecycleManager', () => { projectId: 'test-project-id', }) - expect(verifySignatureStub).to.be.calledWith(mockContents, 'abcdefg') - expect(mockCloudDataSource.getCloudUrl).to.be.calledWith('test') expect(mockCloudDataSource.additionalHeaders).to.be.called expect(readFileStub).to.be.calledWith(path.join(os.tmpdir(), 'cypress', 'cy-prompt', 'abc', 'server', 'index.js'), 'utf8') @@ -184,11 +176,10 @@ describe('CyPromptLifecycleManager', () => { }) const mockManifest = { - 'server/index.js': 'abcdefg', + 'server/index.js': 'c3c4ab913ca059819549f105e756a4c4471df19abef884ce85eafc7b7970e7b4', } ensureCyPromptBundleStub.resolves(mockManifest) - verifySignatureStub.returns(true) const cyPromptManager1 = await cyPromptReadyPromise1 @@ -235,8 +226,6 @@ describe('CyPromptLifecycleManager', () => { projectId: 'test-project-id', }) - expect(verifySignatureStub).to.be.calledWith(mockContents, 'abcdefg') - expect(mockCloudDataSource.getCloudUrl).to.be.calledWith('test') expect(mockCloudDataSource.additionalHeaders).to.be.called expect(readFileStub).to.be.calledWith(path.join(os.tmpdir(), 'cypress', 'cy-prompt', 'abc', 'server', 'index.js'), 'utf8') @@ -333,11 +322,10 @@ describe('CyPromptLifecycleManager', () => { describe('registerCyPromptReadyListener', () => { beforeEach(() => { const mockManifest = { - 'server/index.js': 'abcdefg', + 'server/index.js': 'c3c4ab913ca059819549f105e756a4c4471df19abef884ce85eafc7b7970e7b4', } ensureCyPromptBundleStub.resolves(mockManifest) - verifySignatureStub.returns(true) }) it('registers a listener that will be called when cy-prompt is ready', () => { diff --git a/packages/server/test/unit/cloud/cy-prompt/ensure_cy_prompt_bundle_spec.ts b/packages/server/test/unit/cloud/cy-prompt/ensure_cy_prompt_bundle_spec.ts index 07dad41f02c..2b254b9bd0a 100644 --- a/packages/server/test/unit/cloud/cy-prompt/ensure_cy_prompt_bundle_spec.ts +++ b/packages/server/test/unit/cloud/cy-prompt/ensure_cy_prompt_bundle_spec.ts @@ -9,6 +9,9 @@ describe('ensureCyPromptBundle', () => { let ensureStub: sinon.SinonStub = sinon.stub() let extractStub: sinon.SinonStub = sinon.stub() let getCyPromptBundleStub: 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', } @@ -18,6 +21,8 @@ describe('ensureCyPromptBundle', () => { ensureStub = sinon.stub() extractStub = sinon.stub() getCyPromptBundleStub = sinon.stub() + readFileStub = sinon.stub() + verifySignatureStub = sinon.stub() ensureCyPromptBundle = (proxyquire('../lib/cloud/cy-prompt/ensure_cy_prompt_bundle', { os: { @@ -27,12 +32,16 @@ describe('ensureCyPromptBundle', () => { 'fs-extra': { remove: rmStub.resolves(), ensureDir: ensureStub.resolves(), + readFile: readFileStub.resolves(JSON.stringify(mockManifest)), }, tar: { extract: extractStub.resolves(), }, '../api/cy-prompt/get_cy_prompt_bundle': { - getCyPromptBundle: getCyPromptBundleStub.resolves(mockManifest), + getCyPromptBundle: getCyPromptBundleStub.resolves(mockResponseSignature), + }, + '../encryption': { + verifySignature: verifySignatureStub.resolves(true), }, })).ensureCyPromptBundle }) @@ -49,6 +58,7 @@ describe('ensureCyPromptBundle', () => { expect(rmStub).to.be.calledWith(cyPromptPath) expect(ensureStub).to.be.calledWith(cyPromptPath) + expect(readFileStub).to.be.calledWith(path.join(cyPromptPath, 'manifest.json'), 'utf8') expect(getCyPromptBundleStub).to.be.calledWith({ cyPromptUrl: 'https://cypress.io/cy-prompt', projectId: '123', @@ -60,9 +70,23 @@ describe('ensureCyPromptBundle', () => { cwd: cyPromptPath, }) + expect(verifySignatureStub).to.be.calledWith(JSON.stringify(mockManifest), mockResponseSignature) + expect(manifest).to.deep.eq(mockManifest) }) + it('should throw an error if the cy prompt bundle signature is invalid', async () => { + verifySignatureStub.resolves(false) + + const ensureCyPromptBundlePromise = ensureCyPromptBundle({ + cyPromptPath: '/tmp/cypress/cy-prompt/123', + cyPromptUrl: 'https://cypress.io/cy-prompt', + projectId: '123', + }) + + await expect(ensureCyPromptBundlePromise).to.be.rejectedWith('Unable to verify cy-prompt signature') + }) + it('should throw an error if the cy prompt bundle download times out', async () => { getCyPromptBundleStub.callsFake(() => { return new Promise((resolve) => { diff --git a/packages/types/src/cy-prompt/cy-prompt-server-types.ts b/packages/types/src/cy-prompt/cy-prompt-server-types.ts index deacdae9a44..e9a4d4e22cc 100644 --- a/packages/types/src/cy-prompt/cy-prompt-server-types.ts +++ b/packages/types/src/cy-prompt/cy-prompt-server-types.ts @@ -59,7 +59,7 @@ export interface CyPromptServerOptions { projectSlug?: string cloudApi: CyPromptCloudApi manifest: Record - verifySignature: (script: BinaryLike, signature: string) => boolean + verifyHash: (contents: BinaryLike, expectedHash: string) => boolean getProjectOptions: () => Promise } From 8bd525b7dd838939e94a05b290f641c7965bcfab Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Sun, 22 Jun 2025 08:25:25 -0500 Subject: [PATCH 04/11] fix build --- .../server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts | 8 +++++++- .../unit/cloud/cy-prompt/ensure_cy_prompt_bundle_spec.ts | 3 +++ scripts/binary/binary-sources.js | 2 ++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts b/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts index 339d40d8fd2..78006477f3f 100644 --- a/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts +++ b/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_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 { getCyPromptBundle } from '../api/cy-prompt/get_cy_prompt_bundle' @@ -52,6 +52,12 @@ export const ensureCyPromptBundle = async ({ cyPromptPath, cyPromptUrl, projectI }) const manifestPath = path.join(cyPromptPath, '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/cy-prompt/ensure_cy_prompt_bundle_spec.ts b/packages/server/test/unit/cloud/cy-prompt/ensure_cy_prompt_bundle_spec.ts index 2b254b9bd0a..b89fc7053a6 100644 --- a/packages/server/test/unit/cloud/cy-prompt/ensure_cy_prompt_bundle_spec.ts +++ b/packages/server/test/unit/cloud/cy-prompt/ensure_cy_prompt_bundle_spec.ts @@ -11,6 +11,7 @@ describe('ensureCyPromptBundle', () => { let getCyPromptBundleStub: 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', @@ -23,6 +24,7 @@ describe('ensureCyPromptBundle', () => { getCyPromptBundleStub = sinon.stub() readFileStub = sinon.stub() verifySignatureStub = sinon.stub() + pathExistsStub = sinon.stub() ensureCyPromptBundle = (proxyquire('../lib/cloud/cy-prompt/ensure_cy_prompt_bundle', { os: { @@ -33,6 +35,7 @@ describe('ensureCyPromptBundle', () => { remove: rmStub.resolves(), ensureDir: ensureStub.resolves(), readFile: readFileStub.resolves(JSON.stringify(mockManifest)), + pathExists: pathExistsStub.resolves(true), }, tar: { extract: extractStub.resolves(), diff --git a/scripts/binary/binary-sources.js b/scripts/binary/binary-sources.js index 7e5d0efad46..11f1185956a 100644 --- a/scripts/binary/binary-sources.js +++ b/scripts/binary/binary-sources.js @@ -125,6 +125,8 @@ const getCyPromptFileSource = async (cyPromptFilePath) => { if (!fileContents.includes('process.env.CYPRESS_LOCAL_CY_PROMPT_PATH')) { throw new Error(`Expected to find CYPRESS_LOCAL_CY_PROMPT_PATH in cy prompt file`) } + + return fileContents.replaceAll('process.env.CYPRESS_LOCAL_CY_PROMPT_PATH', 'undefined') } const validateProtocolFile = async (protocolFilePath) => { From 199f8833f99aba918f98f2df3fdc137214851e4c Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Sun, 22 Jun 2025 21:07:30 -0500 Subject: [PATCH 05/11] rework --- .../api/cy-prompt/get_cy_prompt_bundle.ts | 13 +++- .../cy-prompt/ensure_cy_prompt_bundle.ts | 8 +-- .../cy-prompt/get_cy_prompt_bundle_spec.ts | 70 ++++++++++++++++++- 3 files changed, 83 insertions(+), 8 deletions(-) diff --git a/packages/server/lib/cloud/api/cy-prompt/get_cy_prompt_bundle.ts b/packages/server/lib/cloud/api/cy-prompt/get_cy_prompt_bundle.ts index 901e52af196..508f2286339 100644 --- a/packages/server/lib/cloud/api/cy-prompt/get_cy_prompt_bundle.ts +++ b/packages/server/lib/cloud/api/cy-prompt/get_cy_prompt_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 getCyPromptBundle = async ({ cyPromptUrl, projectId, bundlePath }: { cyPromptUrl: string, projectId?: string, bundlePath: string }): Promise => { +export const getCyPromptBundle = async ({ cyPromptUrl, projectId, bundlePath }: { cyPromptUrl: string, projectId?: string, bundlePath: string }): Promise => { let responseSignature: string | null = null + let responseManifestSignature: string | null = null await (asyncRetry(async () => { const response = await fetch(cyPromptUrl, { @@ -33,6 +35,7 @@ export const getCyPromptBundle = async ({ cyPromptUrl, projectId, bundlePath }: } responseSignature = response.headers.get('x-cypress-signature') + responseManifestSignature = response.headers.get('x-cypress-manifest-signature') await new Promise((resolve, reject) => { const writeStream = createWriteStream(bundlePath) @@ -55,5 +58,11 @@ export const getCyPromptBundle = async ({ cyPromptUrl, projectId, bundlePath }: throw new Error('Unable to get cy-prompt signature') } - return responseSignature + const verified = await verifySignatureFromFile(bundlePath, responseSignature) + + if (!verified) { + throw new Error('Unable to verify cy-prompt signature') + } + + return responseManifestSignature } diff --git a/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts b/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts index 78006477f3f..003f757222e 100644 --- a/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts +++ b/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts @@ -31,7 +31,7 @@ export const ensureCyPromptBundle = async ({ cyPromptPath, cyPromptUrl, projectI let timeoutId: NodeJS.Timeout - const responseSignature = await Promise.race([ + const responseManifestSignature = await Promise.race([ getCyPromptBundle({ cyPromptUrl, projectId, @@ -44,7 +44,7 @@ export const ensureCyPromptBundle = async ({ cyPromptPath, cyPromptUrl, projectI }), ]).finally(() => { clearTimeout(timeoutId) - }) as string + }) as string | null await tar.extract({ file: bundlePath, @@ -53,14 +53,14 @@ export const ensureCyPromptBundle = async ({ cyPromptPath, cyPromptUrl, projectI const manifestPath = path.join(cyPromptPath, '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 cy-prompt signature') diff --git a/packages/server/test/unit/cloud/api/cy-prompt/get_cy_prompt_bundle_spec.ts b/packages/server/test/unit/cloud/api/cy-prompt/get_cy_prompt_bundle_spec.ts index e4d92c1885a..6a0e2ca5e6a 100644 --- a/packages/server/test/unit/cloud/api/cy-prompt/get_cy_prompt_bundle_spec.ts +++ b/packages/server/test/unit/cloud/api/cy-prompt/get_cy_prompt_bundle_spec.ts @@ -7,11 +7,13 @@ describe('getCyPromptBundle', () => { let readStream: Readable let createWriteStreamStub: sinon.SinonStub let crossFetchStub: sinon.SinonStub + let verifySignatureFromFileStub: sinon.SinonStub let getCyPromptBundle: typeof import('../../../../../lib/cloud/api/cy-prompt/get_cy_prompt_bundle').getCyPromptBundle beforeEach(() => { createWriteStreamStub = sinon.stub() crossFetchStub = sinon.stub() + verifySignatureFromFileStub = sinon.stub() readStream = Readable.from('console.log("cy-prompt script")') writeResult = '' @@ -35,6 +37,9 @@ describe('getCyPromptBundle', () => { '@packages/root': { version: '1.2.3', }, + '../../encryption': { + verifySignatureFromFile: verifySignatureFromFileStub, + }, }).getCyPromptBundle }) @@ -48,10 +53,16 @@ describe('getCyPromptBundle', () => { if (header === 'x-cypress-signature') { return '159' } + + if (header === 'x-cypress-manifest-signature') { + return '160' + } }, }, }) + verifySignatureFromFileStub.resolves(true) + const projectId = '12345' const responseSignature = await getCyPromptBundle({ cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/cy-prompt/abc/bundle.tar' }) @@ -72,7 +83,9 @@ describe('getCyPromptBundle', () => { expect(writeResult).to.eq('console.log("cy-prompt script")') - expect(responseSignature).to.eq('159') + expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/cy-prompt/abc/bundle.tar', '159') + + expect(responseSignature).to.eq('160') }) it('downloads the cy-prompt bundle and extracts it after 1 fetch failure', async () => { @@ -86,10 +99,16 @@ describe('getCyPromptBundle', () => { if (header === 'x-cypress-signature') { return '159' } + + if (header === 'x-cypress-manifest-signature') { + return '160' + } }, }, }) + verifySignatureFromFileStub.resolves(true) + const projectId = '12345' const responseSignature = await getCyPromptBundle({ cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/cy-prompt/abc/bundle.tar' }) @@ -110,7 +129,9 @@ describe('getCyPromptBundle', () => { expect(writeResult).to.eq('console.log("cy-prompt script")') - expect(responseSignature).to.eq('159') + expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/cy-prompt/abc/bundle.tar', '159') + + expect(responseSignature).to.eq('160') }) it('throws an error and returns a cy-prompt manager in error state if the fetch fails more than twice', async () => { @@ -163,6 +184,51 @@ describe('getCyPromptBundle', () => { }) }) + 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) + + const projectId = '12345' + + await expect(getCyPromptBundle({ cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/cy-prompt/abc/bundle.tar' })).to.be.rejected + + expect(writeResult).to.eq('console.log("cy-prompt script")') + + expect(crossFetchStub).to.be.calledWith('http://localhost:1234/cy-prompt/bundle/abc.tgz', { + agent: sinon.match.any, + method: 'GET', + headers: { + 'x-route-version': '1', + 'x-cypress-signature': '1', + 'x-cypress-project-slug': '12345', + 'x-cypress-cy-prompt-mount-version': '1', + 'x-os-name': 'linux', + 'x-cypress-version': '1.2.3', + }, + encrypt: 'signed', + }) + + expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/cy-prompt/abc/bundle.tar', '159') + }) + it('throws an error if there is no signature in the response headers', async () => { crossFetchStub.resolves({ ok: true, From d40ecd64f37c8efea164c5dfba39357e267ca485 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Wed, 25 Jun 2025 21:15:35 -0500 Subject: [PATCH 06/11] require manifest --- .../api/cy-prompt/get_cy_prompt_bundle.ts | 6 ++- .../cy-prompt/ensure_cy_prompt_bundle.ts | 7 ++-- .../cy-prompt/get_cy_prompt_bundle_spec.ts | 41 ++++++++++++++++++- .../cy-prompt/ensure_cy_prompt_bundle_spec.ts | 12 ++++++ 4 files changed, 59 insertions(+), 7 deletions(-) diff --git a/packages/server/lib/cloud/api/cy-prompt/get_cy_prompt_bundle.ts b/packages/server/lib/cloud/api/cy-prompt/get_cy_prompt_bundle.ts index 508f2286339..7c0c4c14e5c 100644 --- a/packages/server/lib/cloud/api/cy-prompt/get_cy_prompt_bundle.ts +++ b/packages/server/lib/cloud/api/cy-prompt/get_cy_prompt_bundle.ts @@ -10,7 +10,7 @@ import { verifySignatureFromFile } from '../../encryption' const pkg = require('@packages/root') const _delay = linearDelay(500) -export const getCyPromptBundle = async ({ cyPromptUrl, projectId, bundlePath }: { cyPromptUrl: string, projectId?: string, bundlePath: string }): Promise => { +export const getCyPromptBundle = async ({ cyPromptUrl, projectId, bundlePath }: { cyPromptUrl: string, projectId?: string, bundlePath: string }): Promise => { let responseSignature: string | null = null let responseManifestSignature: string | null = null @@ -58,6 +58,10 @@ export const getCyPromptBundle = async ({ cyPromptUrl, projectId, bundlePath }: throw new Error('Unable to get cy-prompt signature') } + if (!responseManifestSignature) { + throw new Error('Unable to get cy-prompt manifest signature') + } + const verified = await verifySignatureFromFile(bundlePath, responseSignature) if (!verified) { diff --git a/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts b/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts index 003f757222e..2ba25cce9fc 100644 --- a/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts +++ b/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts @@ -44,7 +44,7 @@ export const ensureCyPromptBundle = async ({ cyPromptPath, cyPromptUrl, projectI }), ]).finally(() => { clearTimeout(timeoutId) - }) as string | null + }) as string await tar.extract({ file: bundlePath, @@ -53,9 +53,8 @@ export const ensureCyPromptBundle = async ({ cyPromptPath, cyPromptUrl, projectI const manifestPath = path.join(cyPromptPath, '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 cy-prompt manifest') } const manifestContents = await readFile(manifestPath, 'utf8') diff --git a/packages/server/test/unit/cloud/api/cy-prompt/get_cy_prompt_bundle_spec.ts b/packages/server/test/unit/cloud/api/cy-prompt/get_cy_prompt_bundle_spec.ts index 6a0e2ca5e6a..c5e1f4ee265 100644 --- a/packages/server/test/unit/cloud/api/cy-prompt/get_cy_prompt_bundle_spec.ts +++ b/packages/server/test/unit/cloud/api/cy-prompt/get_cy_prompt_bundle_spec.ts @@ -235,13 +235,50 @@ describe('getCyPromptBundle', () => { statusText: 'OK', body: readStream, headers: { - get: () => null, + get: (header) => { + if (header === 'x-cypress-manifest-signature') { + return '160' + } + }, }, }) const projectId = '12345' - await expect(getCyPromptBundle({ cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/cy-prompt/abc/bundle.tar' })).to.be.rejected + await expect(getCyPromptBundle({ cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/cy-prompt/abc/bundle.tar' })).to.be.rejectedWith('Unable to get cy-prompt signature') + + expect(crossFetchStub).to.be.calledWith('http://localhost:1234/cy-prompt/bundle/abc.tgz', { + agent: sinon.match.any, + method: 'GET', + headers: { + 'x-route-version': '1', + 'x-cypress-signature': '1', + 'x-cypress-project-slug': '12345', + 'x-cypress-cy-prompt-mount-version': '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' + } + }, + }, + }) + + const projectId = '12345' + + await expect(getCyPromptBundle({ cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/cy-prompt/abc/bundle.tar' })).to.be.rejectedWith('Unable to get cy-prompt manifest signature') expect(crossFetchStub).to.be.calledWith('http://localhost:1234/cy-prompt/bundle/abc.tgz', { agent: sinon.match.any, diff --git a/packages/server/test/unit/cloud/cy-prompt/ensure_cy_prompt_bundle_spec.ts b/packages/server/test/unit/cloud/cy-prompt/ensure_cy_prompt_bundle_spec.ts index b89fc7053a6..054db8fb021 100644 --- a/packages/server/test/unit/cloud/cy-prompt/ensure_cy_prompt_bundle_spec.ts +++ b/packages/server/test/unit/cloud/cy-prompt/ensure_cy_prompt_bundle_spec.ts @@ -90,6 +90,18 @@ describe('ensureCyPromptBundle', () => { await expect(ensureCyPromptBundlePromise).to.be.rejectedWith('Unable to verify cy-prompt signature') }) + it('should throw an error if the cy prompt bundle manifest is not found', async () => { + pathExistsStub.resolves(false) + + const ensureCyPromptBundlePromise = ensureCyPromptBundle({ + cyPromptPath: '/tmp/cypress/cy-prompt/123', + cyPromptUrl: 'https://cypress.io/cy-prompt', + projectId: '123', + }) + + await expect(ensureCyPromptBundlePromise).to.be.rejectedWith('Unable to find cy-prompt manifest') + }) + it('should throw an error if the cy prompt bundle download times out', async () => { getCyPromptBundleStub.callsFake(() => { return new Promise((resolve) => { From 59e1e52b57e56130eb6ca4da88fa80cb76dbd2be Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Mon, 30 Jun 2025 17:24:46 -0500 Subject: [PATCH 07/11] clean up --- packages/driver/src/cypress/error_messages.ts | 2 +- .../lib/cloud/cy-prompt/CyPromptLifecycleManager.ts | 10 +++------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/driver/src/cypress/error_messages.ts b/packages/driver/src/cypress/error_messages.ts index 9287733b8cd..7b2cb435cb5 100644 --- a/packages/driver/src/cypress/error_messages.ts +++ b/packages/driver/src/cypress/error_messages.ts @@ -1340,7 +1340,7 @@ export default { message: stripIndent`\ Timed out waiting for cy.prompt Cloud code: - - ${obj.error.code}: ${obj.error.message} + - ${obj.error.code ? `${obj.error.code}: ` : ''}${obj.error.message} Check your network connection and system configuration. `, diff --git a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts index bd0e1550239..7031cf47a04 100644 --- a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts +++ b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts @@ -160,14 +160,10 @@ export class CyPromptLifecycleManager { 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_CY_PROMPT_PATH && actualHash !== expectedHash) { - throw new Error('Invalid hash for cy prompt server script') - } + if (!process.env.CYPRESS_LOCAL_CY_PROMPT_PATH && actualHash !== expectedHash) { + throw new Error('Invalid hash for cy prompt server script') } const cyPromptManager = new CyPromptManager() From 228f68055d8713b42e880ed5e2f89d6147c7309a Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Mon, 30 Jun 2025 18:03:21 -0500 Subject: [PATCH 08/11] refactor --- .../cy-prompt/CyPromptLifecycleManager.ts | 15 ++++++++--- .../cy-prompt/ensure_cy_prompt_bundle.ts | 2 +- .../CyPromptLifecycleManager_spec.ts | 26 +++++++++++++++++++ 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts index 7031cf47a04..d386c199f7e 100644 --- a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts +++ b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts @@ -159,11 +159,18 @@ export class CyPromptLifecycleManager { const serverFilePath = path.join(cyPromptPath, '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_CY_PROMPT_PATH && actualHash !== expectedHash) { - throw new Error('Invalid hash for cy prompt server script') + if (!process.env.CYPRESS_LOCAL_CY_PROMPT_PATH) { + const expectedHash = manifest[path.join('server', 'index.js')] + const actualHash = crypto.createHash('sha256').update(script).digest('hex') + + if (!expectedHash) { + throw new Error('cy prompt server script not found in manifest') + } + + if (actualHash !== expectedHash) { + throw new Error('Invalid hash for cy prompt server script') + } } const cyPromptManager = new CyPromptManager() diff --git a/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts b/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts index 2ba25cce9fc..717095c0de2 100644 --- a/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts +++ b/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts @@ -31,7 +31,7 @@ export const ensureCyPromptBundle = async ({ cyPromptPath, cyPromptUrl, projectI let timeoutId: NodeJS.Timeout - const responseManifestSignature = await Promise.race([ + const responseManifestSignature: string = await Promise.race([ getCyPromptBundle({ cyPromptUrl, projectId, diff --git a/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts b/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts index e86bed83ad7..7bebac9169d 100644 --- a/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts +++ b/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts @@ -297,6 +297,32 @@ describe('CyPromptLifecycleManager', () => { expect(mockCyPromptManagerPromise).to.be.present expect(await mockCyPromptManagerPromise).to.equal(updatedCyPromptManager) }) + + it('throws an error when the cy-prompt server script is not found in the manifest', async () => { + cyPromptManagerSetupStub.callsFake((args) => { + return Promise.resolve() + }) + + const mockManifest = {} + + ensureCyPromptBundleStub.resolves(mockManifest) + + cyPromptLifecycleManager.initializeCyPromptManager({ + cloudDataSource: mockCloudDataSource, + ctx: mockCtx, + record: false, + key: '123e4567-e89b-12d3-a456-426614174000', + }) + + // @ts-expect-error - accessing private property + const cyPromptPromise = cyPromptLifecycleManager.cyPromptManagerPromise + + expect(cyPromptPromise).to.not.be.null + + const { error } = await cyPromptPromise + + expect(error.message).to.equal('cy prompt server script not found in manifest') + }) }) describe('getCyPrompt', () => { From 16cdd322e635274d93e6ae795b433b73a1f1a49b Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Mon, 30 Jun 2025 18:09:35 -0500 Subject: [PATCH 09/11] refactor --- packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts index d386c199f7e..b6f6b5ce9bb 100644 --- a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts +++ b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts @@ -161,7 +161,7 @@ export class CyPromptLifecycleManager { const script = await readFile(serverFilePath, 'utf8') if (!process.env.CYPRESS_LOCAL_CY_PROMPT_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 19e661406031df43f437b7cf648ced9c6205c87d Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Tue, 1 Jul 2025 13:23:49 -0500 Subject: [PATCH 10/11] Update packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts Co-authored-by: Matt Schile --- packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts index b6f6b5ce9bb..8767ef6e85e 100644 --- a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts +++ b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts @@ -14,6 +14,7 @@ import chokidar from 'chokidar' import { getCloudMetadata } from '../get_cloud_metadata' import type { CyPromptAuthenticatedUserShape } from '@packages/types' import crypto from 'crypto' + const debug = Debug('cypress:server:cy-prompt-lifecycle-manager') export class CyPromptLifecycleManager { From 3439edd84d3c687d152eefc0426e3235809ca57c Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Tue, 1 Jul 2025 19:52:22 -0500 Subject: [PATCH 11/11] fix test --- .../cy-prompt/CyPromptLifecycleManager.ts | 2 +- .../CyPromptLifecycleManager_spec.ts | 30 ++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts index 8767ef6e85e..27773c6f103 100644 --- a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts +++ b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts @@ -166,7 +166,7 @@ export class CyPromptLifecycleManager { const actualHash = crypto.createHash('sha256').update(script).digest('hex') if (!expectedHash) { - throw new Error('cy prompt server script not found in manifest') + throw new Error('Expected hash for cy prompt server script not found in manifest') } if (actualHash !== expectedHash) { diff --git a/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts b/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts index 7bebac9169d..311baa4a1db 100644 --- a/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts +++ b/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts @@ -321,7 +321,35 @@ describe('CyPromptLifecycleManager', () => { const { error } = await cyPromptPromise - expect(error.message).to.equal('cy prompt server script not found in manifest') + expect(error.message).to.equal('Expected hash for cy prompt server script not found in manifest') + }) + + it('throws an error when the cy-prompt server script is wrong in the manifest', async () => { + cyPromptManagerSetupStub.callsFake((args) => { + return Promise.resolve() + }) + + const mockManifest = { + 'server/index.js': 'a1', + } + + ensureCyPromptBundleStub.resolves(mockManifest) + + cyPromptLifecycleManager.initializeCyPromptManager({ + cloudDataSource: mockCloudDataSource, + ctx: mockCtx, + record: false, + key: '123e4567-e89b-12d3-a456-426614174000', + }) + + // @ts-expect-error - accessing private property + const cyPromptPromise = cyPromptLifecycleManager.cyPromptManagerPromise + + expect(cyPromptPromise).to.not.be.null + + const { error } = await cyPromptPromise + + expect(error.message).to.equal('Invalid hash for cy prompt server script') }) })