Inject serve-sim camera dylib from an unquarantined runtime copy (#7174)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-07-03 17:12:47 -07:00 committed by GitHub
parent d9e0b9e759
commit a4661f15d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 291 additions and 0 deletions

View File

@ -12,6 +12,7 @@ import { app } from 'electron'
import { platform, tmpdir } from 'node:os'
import { delimiter, dirname, join } from 'node:path'
import { EmulatorError } from './emulator-errors'
import { materializeServeSimRuntime } from './serve-sim-runtime-materializer'
const EXEC_TIMEOUT_MS = 90_000
const MAC_OPEN_SHIM_DIR = join(tmpdir(), 'orca-serve-sim-open-shim')
@ -67,6 +68,27 @@ function getServeSimEnv(executable: ServeSimExecutable): NodeJS.ProcessEnv {
return env
}
// Cached per process: materialization is a one-time copy; later calls only stat.
let materializedServeSimPackageDir: string | null | undefined
function resolveMaterializedServeSimPackageDir(bundledPackageDir: string): string | null {
if (materializedServeSimPackageDir !== undefined) {
return materializedServeSimPackageDir
}
materializedServeSimPackageDir = materializeServeSimRuntime({
bundledPackageDir,
targetRootDir: join(app.getPath('userData'), 'serve-sim-runtime'),
version: app.getVersion()
})
if (materializedServeSimPackageDir === null) {
console.warn(
'[serve-sim] runtime materialization failed; running from the app bundle ' +
'(camera injection may hit Gatekeeper on quarantined installs)'
)
}
return materializedServeSimPackageDir
}
export function resolveServeSimExecutable(): ServeSimExecutable {
const bundledResourcesPath =
process.resourcesPath ??
@ -75,6 +97,21 @@ export function resolveServeSimExecutable(): ServeSimExecutable {
: join(app.getPath('exe'), '..', 'resources'))
const bundled = join(bundledResourcesPath, 'serve-sim', 'dist', 'serve-sim.js')
if (existsSync(bundled)) {
if (process.platform === 'darwin') {
// Why: the bundled camera dylib is signed but has no Gatekeeper ticket
// (iOS-simulator arch), so injecting it from the quarantined app bundle
// can trip syspolicyd; run serve-sim from an unquarantined copy instead.
const materializedDir = resolveMaterializedServeSimPackageDir(
join(bundledResourcesPath, 'serve-sim')
)
if (materializedDir) {
return {
command: process.execPath,
baseArgs: [join(materializedDir, 'dist', 'serve-sim.js')],
usesElectronAsNode: true
}
}
}
return { command: process.execPath, baseArgs: [bundled], usesElectronAsNode: true }
}

View File

@ -0,0 +1,160 @@
import { mkdirSync, writeFileSync } from 'node:fs'
import { mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { materializeServeSimRuntime } from './serve-sim-runtime-materializer'
const DYLIB_CONTENT = Buffer.from('signed-simcam-dylib-mach-o-bytes')
async function createBundledServeSimPackage(root: string): Promise<string> {
const packageDir = join(root, 'bundled-serve-sim')
await mkdir(join(packageDir, 'dist', 'simcam'), { recursive: true })
await mkdir(join(packageDir, 'bin'), { recursive: true })
await writeFile(join(packageDir, 'dist', 'serve-sim.js'), 'console.log("serve-sim")')
await writeFile(join(packageDir, 'dist', 'simcam', 'libSimCameraInjector.dylib'), DYLIB_CONTENT, {
mode: 0o644
})
await writeFile(join(packageDir, 'dist', 'simcam', 'serve-sim-camera-helper'), 'helper', {
mode: 0o644
})
await writeFile(join(packageDir, 'bin', 'serve-sim-bin'), 'bin', { mode: 0o644 })
return packageDir
}
describe('materializeServeSimRuntime', () => {
const cleanupPaths: string[] = []
afterEach(async () => {
for (const path of cleanupPaths.splice(0)) {
await rm(path, { recursive: true, force: true })
}
})
async function createRoot(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'orca-simcam-materializer-'))
cleanupPaths.push(root)
return root
}
it('copies the signed dylib through unchanged and clears quarantine', async () => {
const root = await createRoot()
const bundledPackageDir = await createBundledServeSimPackage(root)
const clearQuarantine = vi.fn()
const materialized = materializeServeSimRuntime({
bundledPackageDir,
targetRootDir: join(root, 'runtime'),
version: '1.2.3',
clearQuarantine
})
expect(materialized).toBe(join(root, 'runtime', '1.2.3'))
// The dylib must be byte-identical to the bundled (Developer-ID-signed) copy.
const dylibPath = join(materialized!, 'dist', 'simcam', 'libSimCameraInjector.dylib')
expect(await readFile(dylibPath)).toEqual(DYLIB_CONTENT)
expect(clearQuarantine).toHaveBeenCalledTimes(1)
expect(clearQuarantine).toHaveBeenCalledWith(expect.stringContaining('.staging-1.2.3-'))
if (process.platform !== 'win32') {
for (const executable of [
join(materialized!, 'bin', 'serve-sim-bin'),
join(materialized!, 'dist', 'simcam', 'serve-sim-camera-helper')
]) {
expect(((await stat(executable)).mode & 0o111) !== 0).toBe(true)
}
}
})
it('returns the existing runtime without re-copying', async () => {
const root = await createRoot()
const bundledPackageDir = await createBundledServeSimPackage(root)
const clearQuarantine = vi.fn()
const options = {
bundledPackageDir,
targetRootDir: join(root, 'runtime'),
version: '1.2.3',
clearQuarantine
}
const first = materializeServeSimRuntime(options)
const second = materializeServeSimRuntime(options)
expect(second).toBe(first)
expect(clearQuarantine).toHaveBeenCalledTimes(1)
})
it('prunes runtimes left behind by older app versions', async () => {
const root = await createRoot()
const bundledPackageDir = await createBundledServeSimPackage(root)
const targetRootDir = join(root, 'runtime')
await mkdir(join(targetRootDir, '1.0.0', 'dist'), { recursive: true })
await writeFile(join(targetRootDir, '1.0.0', 'dist', 'serve-sim.js'), 'old')
const materialized = materializeServeSimRuntime({
bundledPackageDir,
targetRootDir,
version: '1.2.3',
clearQuarantine: () => {}
})
expect(materialized).toBe(join(targetRootDir, '1.2.3'))
await expect(stat(join(targetRootDir, '1.0.0'))).rejects.toThrow()
})
it('tolerates a concurrent instance winning the rename', async () => {
const root = await createRoot()
const bundledPackageDir = await createBundledServeSimPackage(root)
const targetRootDir = join(root, 'runtime')
const targetDir = join(targetRootDir, '1.2.3')
// Simulate another instance finishing first: right before our rename, drop a
// complete target dir in place so renameSync fails but the entry exists.
const materialized = materializeServeSimRuntime({
bundledPackageDir,
targetRootDir,
version: '1.2.3',
clearQuarantine: () => {
mkdirSync(join(targetDir, 'dist'), { recursive: true })
writeFileSync(join(targetDir, 'dist', 'serve-sim.js'), 'winner')
}
})
expect(materialized).toBe(targetDir)
expect(await readFile(join(targetDir, 'dist', 'serve-sim.js'), 'utf8')).toBe('winner')
const leftovers = (await readdir(targetRootDir)).filter((name) => name.startsWith('.staging'))
expect(leftovers).toEqual([])
})
it('returns null and leaves no staging behind when quarantine clearing fails', async () => {
const root = await createRoot()
const bundledPackageDir = await createBundledServeSimPackage(root)
const targetRootDir = join(root, 'runtime')
const materialized = materializeServeSimRuntime({
bundledPackageDir,
targetRootDir,
version: '1.2.3',
clearQuarantine: () => {
throw new Error('xattr failed')
}
})
expect(materialized).toBeNull()
await expect(stat(join(targetRootDir, '1.2.3'))).rejects.toThrow()
const leftovers = (await readdir(targetRootDir)).filter((name) => name.startsWith('.staging'))
expect(leftovers).toEqual([])
})
it('returns null when the bundled package is missing', async () => {
const root = await createRoot()
const materialized = materializeServeSimRuntime({
bundledPackageDir: join(root, 'does-not-exist'),
targetRootDir: join(root, 'runtime'),
version: '1.2.3',
clearQuarantine: () => {}
})
expect(materialized).toBeNull()
})
})

View File

@ -0,0 +1,94 @@
import { execFileSync } from 'node:child_process'
import { chmodSync, cpSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from 'node:fs'
import { join } from 'node:path'
export type ServeSimRuntimeMaterializerOptions = {
bundledPackageDir: string
targetRootDir: string
version: string
clearQuarantine?: (dir: string) => void
}
const EXECUTABLE_RELATIVE_PATHS = [
join('bin', 'serve-sim-bin'),
join('dist', 'simcam', 'serve-sim-camera-helper')
]
function defaultClearQuarantine(dir: string): void {
if (process.platform !== 'darwin') {
return
}
// Why: a downloaded/updated .app carries com.apple.quarantine, and cpSync
// clones it onto the copy. serve-sim DYLD-injects libSimCameraInjector.dylib
// (an iOS-simulator binary Apple never Gatekeeper-tickets) into a simulator
// process; if that copy is quarantined, syspolicyd malware-rejects the load.
// Running from an unquarantined copy is what avoids the rejection (#6877).
// Remove only the quarantine attribute (not `-cr`, which strips every xattr);
// recursive `-d` exits 0 even for files that never had it.
execFileSync('/usr/bin/xattr', ['-rd', 'com.apple.quarantine', dir], { timeout: 30_000 })
}
function pruneStaleServeSimRuntimes(targetRootDir: string, keepVersion: string): void {
let entries: string[]
try {
entries = readdirSync(targetRootDir)
} catch {
return
}
for (const entryName of entries) {
if (entryName === keepVersion) {
continue
}
try {
rmSync(join(targetRootDir, entryName), { recursive: true, force: true })
} catch {
// Old-version cleanup is best-effort; a locked file must not block materialization.
}
}
}
// Copies the bundled serve-sim package to a per-version directory outside the
// signed app bundle and strips quarantine, so the camera dylib injected from
// it is not subject to Gatekeeper assessment. The bundled dylib stays signed
// and in place (it must, or the app fails notarization) — this only relocates
// the copy that actually gets DYLD-injected. serve-sim resolves the dylib and
// helper relative to its own entry, so the whole package moves together.
export function materializeServeSimRuntime(
options: ServeSimRuntimeMaterializerOptions
): string | null {
const { bundledPackageDir, targetRootDir, version } = options
const clearQuarantine = options.clearQuarantine ?? defaultClearQuarantine
const targetDir = join(targetRootDir, version)
const entryPath = join(targetDir, 'dist', 'serve-sim.js')
if (existsSync(entryPath)) {
return targetDir
}
const stagingDir = join(targetRootDir, `.staging-${version}-${process.pid}`)
try {
mkdirSync(targetRootDir, { recursive: true })
pruneStaleServeSimRuntimes(targetRootDir, version)
rmSync(stagingDir, { recursive: true, force: true })
rmSync(targetDir, { recursive: true, force: true })
cpSync(bundledPackageDir, stagingDir, { recursive: true })
for (const relativePath of EXECUTABLE_RELATIVE_PATHS) {
const executablePath = join(stagingDir, relativePath)
if (existsSync(executablePath)) {
chmodSync(executablePath, 0o755)
}
}
clearQuarantine(stagingDir)
try {
renameSync(stagingDir, targetDir)
} catch (error) {
// Another app instance sharing userData may have finished first.
if (!existsSync(entryPath)) {
throw error
}
}
return existsSync(entryPath) ? targetDir : null
} catch {
return null
} finally {
rmSync(stagingDir, { recursive: true, force: true })
}
}