diff --git a/src/platforms/apple/core/__tests__/runner-macos-products.test.ts b/src/platforms/apple/core/__tests__/runner-macos-products.test.ts new file mode 100644 index 000000000..b410159f5 --- /dev/null +++ b/src/platforms/apple/core/__tests__/runner-macos-products.test.ts @@ -0,0 +1,108 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { test } from 'vitest'; + +import { MACOS_DEVICE } from '../../../../__tests__/test-utils/device-fixtures.ts'; +import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; +import { AppError } from '@agent-device/kernel/errors'; +import { + isExpectedRunnerRepairFailure, + repairMacOsRunnerProductsIfNeeded, +} from '../runner/runner-macos-products.ts'; +import { createLocalAppleToolProvider, withAppleToolProvider } from '../tool-provider.ts'; + +const XCTESTRUN_PATH = '/tmp/agent-device-runner.xctestrun'; + +test('repair discovers and re-signs nested code before the product', async () => { + const { productPath, embeddedItemPaths } = createProduct(); + const calls: string[][] = []; + const provider = createCodesignProvider( + calls, + (args) => args.includes('--sign') && args.at(-1) === productPath, + ); + + await withAppleToolProvider(provider, async () => { + await repairMacOsRunnerProductsIfNeeded(MACOS_DEVICE, [productPath], XCTESTRUN_PATH); + }); + + const signingArgs = [ + '--force', + '--preserve-metadata=identifier,entitlements,flags,runtime', + '--sign', + '-', + ]; + assert.deepEqual(calls.slice(1, -1), [ + ...embeddedItemPaths.map((itemPath) => [...signingArgs, itemPath]), + [...signingArgs, productPath], + ]); + assert.deepEqual(calls.at(-1), ['--verify', '--deep', '--strict', productPath]); +}); + +test('repair fails when re-signing leaves the product unverifiable', async () => { + const productPath = createProductPath(); + const provider = createCodesignProvider([], () => false); + + const error = await withAppleToolProvider( + provider, + async () => + await repairMacOsRunnerProductsIfNeeded(MACOS_DEVICE, [productPath], XCTESTRUN_PATH).then( + () => null, + (thrown: unknown) => thrown, + ), + ); + + assert.ok(error instanceof AppError); + assert.equal(isExpectedRunnerRepairFailure(error), true); +}); + +function createProductPath(): string { + return createProduct().productPath; +} + +function createProduct(): { + productPath: string; + embeddedItemPaths: string[]; +} { + const root = mkdtempForTestSync('agent-device-runner-products-'); + const productPath = path.join(root, 'AgentDeviceRunnerUITests-Runner.app'); + fs.mkdirSync(productPath); + const frameworksRoot = path.join(productPath, 'Contents', 'Frameworks'); + const embeddedItemPaths = [ + 'FutureTestSupport.framework', + 'AnotherTestSupport.framework', + 'libFutureTestSupport.dylib', + ].map((itemName) => path.join(frameworksRoot, itemName)); + for (const itemPath of embeddedItemPaths) { + if (itemPath.endsWith('.framework')) { + fs.mkdirSync(itemPath, { recursive: true }); + } else { + fs.mkdirSync(path.dirname(itemPath), { recursive: true }); + fs.writeFileSync(itemPath, ''); + } + } + return { productPath, embeddedItemPaths: embeddedItemPaths.sort() }; +} + +function createCodesignProvider( + calls: string[][], + isRepairingSignature: (args: readonly string[]) => boolean, +) { + let repaired = false; + return createLocalAppleToolProvider({ + runCommand: async (command, args = []) => { + assert.equal(command, 'codesign'); + calls.push([...args]); + if (isRepairingSignature(args)) { + repaired = true; + } + const verifying = args[0] === '--verify'; + return { + exitCode: verifying && !repaired ? 1 : 0, + stdout: '', + stderr: '', + }; + }, + }); +} diff --git a/src/platforms/apple/core/runner/runner-macos-products.ts b/src/platforms/apple/core/runner/runner-macos-products.ts index b3e177df2..bf5c578d2 100644 --- a/src/platforms/apple/core/runner/runner-macos-products.ts +++ b/src/platforms/apple/core/runner/runner-macos-products.ts @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import path from 'node:path'; import { isMacOs, type DeviceInfo } from '@agent-device/kernel/device'; import { AppError, asAppError } from '@agent-device/kernel/errors'; import { runAppleToolCommand } from '../tool-provider.ts'; @@ -8,6 +9,14 @@ const RUNNER_PRODUCT_REPAIR_FAILURE_REASONS = new Set([ 'RUNNER_PRODUCT_REPAIR_FAILED', ]); +const AD_HOC_RESIGN_ARGS = [ + '--force', + // Designated requirements must be regenerated for the ad-hoc identity. + '--preserve-metadata=identifier,entitlements,flags,runtime', + '--sign', + '-', +] as const; + export async function repairMacOsRunnerProductsIfNeeded( device: DeviceInfo, productPaths: string[], @@ -39,24 +48,52 @@ export async function repairMacOsRunnerProductsIfNeeded( if (await hasValidCodeSignature(productPath)) { continue; } - await runAppleToolCommand('codesign', ['--remove-signature', productPath], { - allowFailure: true, - }); - try { - await runAppleToolCommand('codesign', ['--force', '--sign', '-', productPath]); - } catch (error) { - const appError = asAppError(error, 'COMMAND_FAILED'); - throw new AppError('COMMAND_FAILED', 'Failed to repair macOS runner product signature', { - reason: 'RUNNER_PRODUCT_REPAIR_FAILED', - productPath, - xctestrunPath, - error: appError.message, - details: appError.details, - }); + await resignRunnerProduct(productPath, xctestrunPath); + } +} + +async function resignRunnerProduct(productPath: string, xctestrunPath: string): Promise { + try { + const frameworksPath = path.join(productPath, 'Contents', 'Frameworks'); + const embeddedCodePaths = fs.existsSync(frameworksPath) + ? fs + .readdirSync(frameworksPath) + .sort() + .map((itemName) => path.join(frameworksPath, itemName)) + : []; + for (const embeddedCodePath of embeddedCodePaths) { + await runAppleToolCommand('codesign', [...AD_HOC_RESIGN_ARGS, embeddedCodePath]); } + await runAppleToolCommand('codesign', [...AD_HOC_RESIGN_ARGS, productPath]); + } catch (error) { + const appError = asAppError(error, 'COMMAND_FAILED'); + throw repairFailure(productPath, xctestrunPath, appError.message, appError.details); + } + + if (!(await hasValidCodeSignature(productPath))) { + throw repairFailure( + productPath, + xctestrunPath, + 'Product still fails code signature verification after re-signing', + ); } } +function repairFailure( + productPath: string, + xctestrunPath: string, + error: string, + details?: unknown, +): AppError { + return new AppError('COMMAND_FAILED', 'Failed to repair macOS runner product signature', { + reason: 'RUNNER_PRODUCT_REPAIR_FAILED', + productPath, + xctestrunPath, + error, + details, + }); +} + export function isExpectedRunnerRepairFailure(error: unknown): boolean { if (!(error instanceof AppError)) { return false;