From b3b0cb408548593512476a99a142bf7a72c9ddae Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Fri, 23 Oct 2026 12:56:22 -0700 Subject: [PATCH 1/9] [vm-tools] Run guest startup scripts Edited-by: Codex Co-Authored-By: Claude Opus 5.5 --- .changeset/config.json | 3 +- .changeset/vm-tools.md | 5 + packages/vm-tools/README.md | 23 +++ packages/vm-tools/bin/vm-tools | 2 + packages/vm-tools/jest.config.ts | 12 ++ packages/vm-tools/package.json | 37 ++++ .../resources/com.expo.vm-tools.plist | 16 ++ packages/vm-tools/src/__tests__/main.test.ts | 178 ++++++++++++++++++ packages/vm-tools/src/commands/daemon.ts | 71 +++++++ packages/vm-tools/src/commands/install.ts | 32 ++++ packages/vm-tools/src/main.ts | 29 +++ packages/vm-tools/src/utils/state.ts | 6 + packages/vm-tools/tsconfig.json | 16 ++ yarn.lock | 15 ++ 14 files changed, 444 insertions(+), 1 deletion(-) create mode 100644 .changeset/vm-tools.md create mode 100644 packages/vm-tools/README.md create mode 100755 packages/vm-tools/bin/vm-tools create mode 100644 packages/vm-tools/jest.config.ts create mode 100644 packages/vm-tools/package.json create mode 100644 packages/vm-tools/resources/com.expo.vm-tools.plist create mode 100644 packages/vm-tools/src/__tests__/main.test.ts create mode 100644 packages/vm-tools/src/commands/daemon.ts create mode 100644 packages/vm-tools/src/commands/install.ts create mode 100644 packages/vm-tools/src/main.ts create mode 100644 packages/vm-tools/src/utils/state.ts create mode 100644 packages/vm-tools/tsconfig.json diff --git a/.changeset/config.json b/.changeset/config.json index ceb6b0884f..9313193c89 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -19,7 +19,8 @@ "@expo/downloader", "@expo/turtle-spawn", "@expo/template-file", - "create-eas-build-function" + "create-eas-build-function", + "@expo/vm-tools" ] ], "linked": [], diff --git a/.changeset/vm-tools.md b/.changeset/vm-tools.md new file mode 100644 index 0000000000..2d715e2625 --- /dev/null +++ b/.changeset/vm-tools.md @@ -0,0 +1,5 @@ +--- +'@expo/vm-tools': minor +--- + +Add a guest startup daemon that runs the script supplied when creating a VM. diff --git a/packages/vm-tools/README.md b/packages/vm-tools/README.md new file mode 100644 index 0000000000..13241d7a68 --- /dev/null +++ b/packages/vm-tools/README.md @@ -0,0 +1,23 @@ +# @expo/vm-tools + +Run a startup script supplied when creating a guest VM. + +```sh +bunx @expo/vm-tools@latest install +``` + +Installation copies a standalone daemon and a small Aqua LaunchAgent into the guest user's home. +It does not load the agent or run a startup script. The LaunchAgent uses the guest Node executable that ran installation; it must remain available. +Automatic GUI login is required. At login, the daemon waits for +`/Volumes/My Shared Files/expo-startup/startup.sh` and runs it with `/bin/bash` once per guest boot. +The script does not need executable permission. Anka supplies its directory as a read-only mount +before starting Tart. + +The daemon waits for the script to exit and returns its exit status. It does not restart failed +scripts or interpret workload configuration, environment sidecars, or readiness. The script owns +its environment, processes, and cleanup; use `exec` for a foreground workload so termination signals +reach it. The LaunchAgent has `KeepAlive=false`. + +Files use inherited OS permissions. vm-tools does not set a process umask or explicit file modes. + +Run `corepack yarn workspace @expo/vm-tools test --runInBand`, `typecheck`, and `build`. diff --git a/packages/vm-tools/bin/vm-tools b/packages/vm-tools/bin/vm-tools new file mode 100755 index 0000000000..23c4c41457 --- /dev/null +++ b/packages/vm-tools/bin/vm-tools @@ -0,0 +1,2 @@ +#!/usr/bin/env node +require('../dist/index').runCliAsync(); diff --git a/packages/vm-tools/jest.config.ts b/packages/vm-tools/jest.config.ts new file mode 100644 index 0000000000..9d280c0859 --- /dev/null +++ b/packages/vm-tools/jest.config.ts @@ -0,0 +1,12 @@ +import type { Config } from '@jest/types'; + +import baseConfig from '../../jest/jest.shared.config'; + +const config: Config.InitialOptions = { + ...baseConfig, + rootDir: 'src', + testMatch: ['**/__tests__/*.test.ts'], + clearMocks: true, +}; + +export default config; diff --git a/packages/vm-tools/package.json b/packages/vm-tools/package.json new file mode 100644 index 0000000000..4148bf8f88 --- /dev/null +++ b/packages/vm-tools/package.json @@ -0,0 +1,37 @@ +{ + "name": "@expo/vm-tools", + "version": "25.3.0", + "description": "Run a guest startup script supplied when creating a VM", + "bugs": "https://github.com/expo/eas-cli/issues", + "license": "BUSL-1.1", + "author": "Expo ", + "repository": { + "type": "git", + "url": "https://github.com/expo/eas-cli.git", + "directory": "packages/vm-tools" + }, + "bin": "bin/vm-tools", + "files": [ + "bin", + "dist", + "resources" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "test": "jest --config jest.config.ts", + "build": "ncc build src/main.ts -o dist --no-cache --no-source-map-register", + "typecheck": "tsc --noEmit", + "prepack": "yarn build", + "rebuild": "yarn build" + }, + "devDependencies": { + "@types/jest": "^29.5.12", + "@types/node": "22.20.5", + "@vercel/ncc": "^0.38.1", + "jest": "^29.7.0", + "ts-jest": "^29.4.12", + "typescript": "^6.0.3" + } +} diff --git a/packages/vm-tools/resources/com.expo.vm-tools.plist b/packages/vm-tools/resources/com.expo.vm-tools.plist new file mode 100644 index 0000000000..9184ec92a3 --- /dev/null +++ b/packages/vm-tools/resources/com.expo.vm-tools.plist @@ -0,0 +1,16 @@ + + + + + Labelcom.expo.vm-tools + LimitLoadToSessionTypeAqua + RunAtLoad + KeepAlive + ExitTimeOut150 + WorkingDirectory__HOME__ + StandardOutPath__HOME__/Library/Logs/vm-tools.log + StandardErrorPath__HOME__/Library/Logs/vm-tools.log + ProgramArguments + __NODE____HOME__/.expo/vm-tools/rundaemon + + diff --git a/packages/vm-tools/src/__tests__/main.test.ts b/packages/vm-tools/src/__tests__/main.test.ts new file mode 100644 index 0000000000..4c1e4cf8f0 --- /dev/null +++ b/packages/vm-tools/src/__tests__/main.test.ts @@ -0,0 +1,178 @@ +import { ChildProcess, execFile, spawn } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; + +import { runStartupScriptOncePerBootAsync } from '../commands/daemon'; +import { installStartupDaemonAsync } from '../commands/install'; + +jest.mock('node:child_process', () => { + const actual = jest.requireActual('node:child_process'); + const { promisify } = jest.requireActual('node:util'); + return { + ...actual, + spawn: jest.fn(), + execFile: Object.assign(jest.fn(), { + [promisify.custom]: jest.fn(), + }), + }; +}); + +let root: string; +let home: string; +let directory: string; +let child: ChildProcess; + +beforeEach(async () => { + jest.resetAllMocks(); + root = await fs.mkdtemp(path.join(os.tmpdir(), 'vm-tools-test-')); + home = path.join(root, 'guest & home'); + directory = path.join(root, 'read only startup'); + await fs.mkdir(home); + await fs.mkdir(directory); + jest.spyOn(os, 'homedir').mockReturnValue(home); + jest.mocked(promisify(execFile)).mockResolvedValue({ stdout: 'boot-one\n', stderr: '' }); + child = Object.assign(new EventEmitter(), { kill: jest.fn() }) as unknown as ChildProcess; + jest.mocked(spawn).mockImplementation(() => { + setImmediate(() => child.emit('exit', 0, null)); + return child; + }); +}); + +afterEach(async () => { + jest.restoreAllMocks(); + await fs.rm(root, { recursive: true, force: true }); +}); + +test('install writes an offline runner and Aqua agent without running a payload', async () => { + const runnerPath = path.join(__dirname, '../main.ts'); + await installStartupDaemonAsync( + runnerPath, + path.join(__dirname, '../../resources/com.expo.vm-tools.plist') + ); + const plist = await fs.readFile( + path.join(home, 'Library/LaunchAgents/com.expo.vm-tools.plist'), + 'utf8' + ); + expect(plist).toContain('guest & home'); + expect(plist).toMatch(/KeepAlive<\/key>\s*/); + expect(plist).toMatch(/LimitLoadToSessionType<\/key>\s*Aqua<\/string>/); + expect(plist).toContain(process.execPath); + expect(plist).not.toContain('Umask'); + expect(plist).toContain('daemon'); + expect(plist).not.toContain('__NODE__'); + expect(await fs.readFile(path.join(home, '.expo/vm-tools/run'))).toEqual( + await fs.readFile(runnerPath) + ); + expect(spawn).not.toHaveBeenCalled(); + expect(promisify(execFile)).not.toHaveBeenCalled(); +}); + +test('waits for a nonexecutable script and executes it once with bash', async () => { + const pending = runStartupScriptOncePerBootAsync(directory); + await new Promise(resolve => setTimeout(resolve, 20)); + expect(spawn).not.toHaveBeenCalled(); + const script = path.join(directory, 'startup.sh'); + await fs.writeFile(script, 'exit 0\n'); + await expect(pending).resolves.toBe(0); + expect(spawn).toHaveBeenCalledWith('/bin/bash', [script], { stdio: 'inherit' }); + await expect(runStartupScriptOncePerBootAsync(directory)).resolves.toBe(0); + expect(spawn).toHaveBeenCalledTimes(1); +}); + +test('waits for child exit, returns its error code, and does not retry', async () => { + await fs.writeFile(path.join(directory, 'startup.sh'), 'exit 7\n'); + let launched!: () => void; + const started = new Promise(resolve => { + launched = resolve; + }); + jest.mocked(spawn).mockImplementation(() => { + launched(); + return child; + }); + let settled = false; + const pending = runStartupScriptOncePerBootAsync(directory).then(code => { + settled = true; + return code; + }); + await started; + expect(settled).toBe(false); + child.emit('exit', 7, null); + await expect(pending).resolves.toBe(7); + await expect(runStartupScriptOncePerBootAsync(directory)).resolves.toBe(0); + expect(spawn).toHaveBeenCalledTimes(1); +}); + +test('a launch error consumes this boot instead of restarting the script', async () => { + await fs.writeFile(path.join(directory, 'startup.sh'), 'exit 0\n'); + jest.mocked(spawn).mockImplementation(() => { + setImmediate(() => child.emit('error', new Error('spawn failed'))); + return child; + }); + await expect(runStartupScriptOncePerBootAsync(directory)).rejects.toThrow('spawn failed'); + await expect(runStartupScriptOncePerBootAsync(directory)).resolves.toBe(0); + expect(spawn).toHaveBeenCalledTimes(1); +}); + +test('concurrent dispatchers execute only one payload', async () => { + await fs.writeFile(path.join(directory, 'startup.sh'), 'exit 0\n'); + await expect( + Promise.all([ + runStartupScriptOncePerBootAsync(directory), + runStartupScriptOncePerBootAsync(directory), + ]) + ).resolves.toEqual([0, 0]); + expect(spawn).toHaveBeenCalledTimes(1); +}); + +test('forwards teardown signals to the foreground script and waits for its exit', async () => { + await fs.writeFile(path.join(directory, 'startup.sh'), 'exit 0\n'); + let launched!: () => void; + const started = new Promise(resolve => { + launched = resolve; + }); + jest.mocked(spawn).mockImplementation(() => { + launched(); + return child; + }); + const pending = runStartupScriptOncePerBootAsync(directory); + await started; + process.emit('SIGTERM', 'SIGTERM'); + expect(child.kill).toHaveBeenCalledWith('SIGTERM'); + child.emit('exit', null, 'SIGTERM'); + await expect(pending).resolves.toBe(1); + await expect(runStartupScriptOncePerBootAsync(directory)).resolves.toBe(0); + expect(spawn).toHaveBeenCalledTimes(1); +}); + +test('teardown before launch releases this boot for a later invocation', async () => { + await fs.writeFile(path.join(directory, 'startup.sh'), 'exit 0\n'); + const mkdir = fs.mkdir; + jest.spyOn(fs, 'mkdir').mockImplementation(async (target, options) => { + const result = await mkdir(target, options); + if (path.basename(String(target)) === 'started-boot-one') { + process.emit('SIGTERM', 'SIGTERM'); + } + return result; + }); + await expect(runStartupScriptOncePerBootAsync(directory)).rejects.toThrow( + 'VM startup was interrupted.' + ); + expect(spawn).not.toHaveBeenCalled(); + await expect(fs.stat(path.join(home, '.expo/vm-tools/started-boot-one'))).rejects.toMatchObject({ + code: 'ENOENT', + }); + jest.mocked(fs.mkdir).mockRestore(); + await expect(runStartupScriptOncePerBootAsync(directory)).resolves.toBe(0); + expect(spawn).toHaveBeenCalledTimes(1); +}); + +test('teardown cancels waiting for a missing mount without executing anything', async () => { + const pending = runStartupScriptOncePerBootAsync(directory); + const rejected = expect(pending).rejects.toThrow(); + process.emit('SIGTERM', 'SIGTERM'); + await rejected; + expect(spawn).not.toHaveBeenCalled(); +}); diff --git a/packages/vm-tools/src/commands/daemon.ts b/packages/vm-tools/src/commands/daemon.ts new file mode 100644 index 0000000000..47c8122e07 --- /dev/null +++ b/packages/vm-tools/src/commands/daemon.ts @@ -0,0 +1,71 @@ +import { execFile, spawn } from 'node:child_process'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { setTimeout as sleepAsync } from 'node:timers/promises'; +import { promisify } from 'node:util'; + +import { getVmStateDirectory } from '../utils/state'; + +const STARTUP_DIRECTORY = '/Volumes/My Shared Files/expo-startup'; +const STARTUP_SCRIPT = 'startup.sh'; +const execFilePromise = promisify(execFile); + +export async function runStartupScriptOncePerBootAsync( + directory = STARTUP_DIRECTORY +): Promise { + const controller = new AbortController(); + let child: ReturnType | undefined; + const stop = (signal: NodeJS.Signals): void => { + controller.abort(new Error('VM startup was interrupted.')); + child?.kill(signal); + }; + process.on('SIGTERM', stop); + process.on('SIGINT', stop); + try { + const script = path.join(directory, STARTUP_SCRIPT); + while (true) { + controller.signal.throwIfAborted(); + try { + if (!(await fs.stat(script)).isFile()) { + throw new Error('The mounted VM startup.sh is not a regular file.'); + } + break; + } catch (error) { + if (!['ENOENT', 'ENOTDIR'].includes((error as NodeJS.ErrnoException).code ?? '')) { + throw error; + } + } + await sleepAsync(100, undefined, { signal: controller.signal }); + } + const { stdout } = await execFilePromise('/usr/sbin/sysctl', ['-n', 'kern.bootsessionuuid']); + const boot = stdout.trim(); + if (!boot) { + throw new Error( + 'Could not read the VM boot identifier (kern.bootsessionuuid); startup cannot continue.' + ); + } + await fs.mkdir(getVmStateDirectory(), { recursive: true }); + const marker = path.join(getVmStateDirectory(), `started-${boot}`); + try { + await fs.mkdir(marker); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + return 0; + } + throw error; + } + if (controller.signal.aborted) { + // Nothing ran yet, so release this boot for a later invocation. + await fs.rmdir(marker); + controller.signal.throwIfAborted(); + } + child = spawn('/bin/bash', [script], { stdio: 'inherit' }); + return await new Promise((resolve, reject) => { + child!.once('error', reject); + child!.once('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0))); + }); + } finally { + process.off('SIGTERM', stop); + process.off('SIGINT', stop); + } +} diff --git a/packages/vm-tools/src/commands/install.ts b/packages/vm-tools/src/commands/install.ts new file mode 100644 index 0000000000..8b55299896 --- /dev/null +++ b/packages/vm-tools/src/commands/install.ts @@ -0,0 +1,32 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { getVmStateDirectory } from '../utils/state'; + +const LABEL = 'com.expo.vm-tools'; + +function escapeXml(value: string): string { + return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); +} + +export async function installStartupDaemonAsync( + runnerPath: string, + plistPath: string +): Promise { + const home = os.homedir(); + const directory = getVmStateDirectory(); + await Promise.all( + [directory, path.join(home, 'Library/LaunchAgents'), path.join(home, 'Library/Logs')].map( + filename => fs.mkdir(filename, { recursive: true }) + ) + ); + await fs.copyFile(runnerPath, path.join(directory, 'run')); + const plist = await fs.readFile(plistPath, 'utf8'); + await fs.writeFile( + path.join(home, `Library/LaunchAgents/${LABEL}.plist`), + plist + .replaceAll('__HOME__', escapeXml(home)) + .replaceAll('__NODE__', escapeXml(process.execPath)) + ); +} diff --git a/packages/vm-tools/src/main.ts b/packages/vm-tools/src/main.ts new file mode 100644 index 0000000000..2f8d50b76a --- /dev/null +++ b/packages/vm-tools/src/main.ts @@ -0,0 +1,29 @@ +import path from 'node:path'; + +import { runStartupScriptOncePerBootAsync } from './commands/daemon'; +import { installStartupDaemonAsync } from './commands/install'; + +export async function runCliAsync(): Promise { + try { + switch (process.argv[2]) { + case 'install': + await installStartupDaemonAsync( + __filename, + path.join(__dirname, '../resources/com.expo.vm-tools.plist') + ); + break; + case 'daemon': + process.exitCode = await runStartupScriptOncePerBootAsync(); + break; + default: + throw new Error('Usage: vm-tools '); + } + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } +} + +if (require.main === module) { + void runCliAsync(); +} diff --git a/packages/vm-tools/src/utils/state.ts b/packages/vm-tools/src/utils/state.ts new file mode 100644 index 0000000000..c8d4da72d6 --- /dev/null +++ b/packages/vm-tools/src/utils/state.ts @@ -0,0 +1,6 @@ +import os from 'node:os'; +import path from 'node:path'; + +export function getVmStateDirectory(): string { + return path.join(os.homedir(), '.expo/vm-tools'); +} diff --git a/packages/vm-tools/tsconfig.json b/packages/vm-tools/tsconfig.json new file mode 100644 index 0000000000..b331298a1c --- /dev/null +++ b/packages/vm-tools/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "moduleResolution": "node", + "ignoreDeprecations": "6.0", + "target": "es2022", + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "types": ["node", "jest"], + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src/**/*"] +} diff --git a/yarn.lock b/yarn.lock index c07393872b..a9da453c83 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3200,6 +3200,21 @@ __metadata: languageName: unknown linkType: soft +"@expo/vm-tools@workspace:packages/vm-tools": + version: 0.0.0-use.local + resolution: "@expo/vm-tools@workspace:packages/vm-tools" + dependencies: + "@types/jest": "npm:^29.5.12" + "@types/node": "npm:22.20.5" + "@vercel/ncc": "npm:^0.38.1" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.4.12" + typescript: "npm:^6.0.3" + bin: + vm-tools: bin/vm-tools + languageName: unknown + linkType: soft + "@expo/worker@workspace:packages/worker": version: 0.0.0-use.local resolution: "@expo/worker@workspace:packages/worker" From 3aa2b259fbfc8fe72086214bbd4fadac4703bea8 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Sun, 25 Oct 2026 18:56:32 -0700 Subject: [PATCH 2/9] [vm-tools] Use the shared warm Simulator terms in the changeset Co-Authored-By: Claude Opus 5.5 --- .changeset/vm-tools.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/vm-tools.md b/.changeset/vm-tools.md index 2d715e2625..dfa0c12575 100644 --- a/.changeset/vm-tools.md +++ b/.changeset/vm-tools.md @@ -2,4 +2,4 @@ '@expo/vm-tools': minor --- -Add a guest startup daemon that runs the script supplied when creating a VM. +Add a launchd agent for the guest VM that runs the mounted startup script once per boot. From cbd42cfe5c07b9a2b40187681f7b4125cef5b271 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 26 Oct 2026 00:18:34 -0700 Subject: [PATCH 3/9] [vm-tools] Keep the startup process handle explicit --- packages/vm-tools/src/commands/daemon.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/vm-tools/src/commands/daemon.ts b/packages/vm-tools/src/commands/daemon.ts index 47c8122e07..0766bccdc3 100644 --- a/packages/vm-tools/src/commands/daemon.ts +++ b/packages/vm-tools/src/commands/daemon.ts @@ -59,10 +59,11 @@ export async function runStartupScriptOncePerBootAsync( await fs.rmdir(marker); controller.signal.throwIfAborted(); } - child = spawn('/bin/bash', [script], { stdio: 'inherit' }); + const startupProcess = spawn('/bin/bash', [script], { stdio: 'inherit' }); + child = startupProcess; return await new Promise((resolve, reject) => { - child!.once('error', reject); - child!.once('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0))); + startupProcess.once('error', reject); + startupProcess.once('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0))); }); } finally { process.off('SIGTERM', stop); From 602b8b39cb2444f47f2f572395ef9ce024aaab5f Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 26 Oct 2026 15:47:55 -0700 Subject: [PATCH 4/9] [vm-tools] Describe the VM startup script loader --- .changeset/vm-tools.md | 2 +- packages/vm-tools/README.md | 28 +++++++++++++----------- packages/vm-tools/package.json | 2 +- packages/vm-tools/src/commands/daemon.ts | 2 +- 4 files changed, 18 insertions(+), 16 deletions(-) diff --git a/.changeset/vm-tools.md b/.changeset/vm-tools.md index dfa0c12575..a81cfb4f2b 100644 --- a/.changeset/vm-tools.md +++ b/.changeset/vm-tools.md @@ -2,4 +2,4 @@ '@expo/vm-tools': minor --- -Add a launchd agent for the guest VM that runs the mounted startup script once per boot. +Add a macOS startup script loader that runs startup.sh once per VM boot. diff --git a/packages/vm-tools/README.md b/packages/vm-tools/README.md index 13241d7a68..cb39d28b68 100644 --- a/packages/vm-tools/README.md +++ b/packages/vm-tools/README.md @@ -1,23 +1,25 @@ # @expo/vm-tools -Run a startup script supplied when creating a guest VM. +A startup script loader for macOS VMs. + +## Install ```sh bunx @expo/vm-tools@latest install ``` -Installation copies a standalone daemon and a small Aqua LaunchAgent into the guest user's home. -It does not load the agent or run a startup script. The LaunchAgent uses the guest Node executable that ran installation; it must remain available. -Automatic GUI login is required. At login, the daemon waits for -`/Volumes/My Shared Files/expo-startup/startup.sh` and runs it with `/bin/bash` once per guest boot. -The script does not need executable permission. Anka supplies its directory as a read-only mount -before starting Tart. +Installs a per-user launchd agent and a standalone runner. The agent starts at the next GUI login; +installation does not load it or run a script. Automatic GUI login is required, and the Node +executable used for installation must remain available. + +## Startup script -The daemon waits for the script to exit and returns its exit status. It does not restart failed -scripts or interpret workload configuration, environment sidecars, or readiness. The script owns -its environment, processes, and cleanup; use `exec` for a foreground workload so termination signals -reach it. The LaunchAgent has `KeepAlive=false`. +At login, vm-tools waits for `/Volumes/My Shared Files/expo-startup/startup.sh` and runs it with +`/bin/bash` once per VM boot. Put the file in the VM or provide it through a shared directory. +The script does not need executable permission. -Files use inherited OS permissions. vm-tools does not set a process umask or explicit file modes. +The runner waits for the script to exit and returns its exit status. Failed scripts are not +restarted. The script owns its environment, processes and cleanup; use `exec` for a foreground +command so termination signals reach it. -Run `corepack yarn workspace @expo/vm-tools test --runInBand`, `typecheck`, and `build`. +Output is written to `~/Library/Logs/vm-tools.log`. diff --git a/packages/vm-tools/package.json b/packages/vm-tools/package.json index 4148bf8f88..131f8db897 100644 --- a/packages/vm-tools/package.json +++ b/packages/vm-tools/package.json @@ -1,7 +1,7 @@ { "name": "@expo/vm-tools", "version": "25.3.0", - "description": "Run a guest startup script supplied when creating a VM", + "description": "VM startup script loader", "bugs": "https://github.com/expo/eas-cli/issues", "license": "BUSL-1.1", "author": "Expo ", diff --git a/packages/vm-tools/src/commands/daemon.ts b/packages/vm-tools/src/commands/daemon.ts index 0766bccdc3..d4640fe6ad 100644 --- a/packages/vm-tools/src/commands/daemon.ts +++ b/packages/vm-tools/src/commands/daemon.ts @@ -27,7 +27,7 @@ export async function runStartupScriptOncePerBootAsync( controller.signal.throwIfAborted(); try { if (!(await fs.stat(script)).isFile()) { - throw new Error('The mounted VM startup.sh is not a regular file.'); + throw new Error('VM startup.sh must be a regular file.'); } break; } catch (error) { From 64ef782db9ec5527ef7a7de305905ae7c7aeb3a9 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 26 Oct 2026 16:38:00 -0700 Subject: [PATCH 5/9] [vm-tools] Refresh checks against the current base No source changes. Refresh the pull-request event after its old base SHA was compared with a newer GitHub merge checkout. From f5b60f350e62914af700bbac6ffd4e745230ae8b Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 26 Oct 2026 16:45:38 -0700 Subject: [PATCH 6/9] [ci] Check the pull-request source for changesets Do not include main-only changelogs from GitHub merge checkouts when the stack reports an older base SHA. Keep all existing validation rules. --- .github/workflows/changelog.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index 66daf52944..a316caee50 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -32,6 +32,7 @@ jobs: steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} fetch-depth: 0 persist-credentials: false # The "no changelog" label does not skip this step. From a549bceaff89a16a7cc57694780a46301e24ae78 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 26 Oct 2026 17:07:14 -0700 Subject: [PATCH 7/9] [ci] Compare changes with the current base branch --- .github/workflows/changelog.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index a316caee50..88939a09f0 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -28,7 +28,7 @@ jobs: contains(github.event.pull_request.labels.*.name, 'no changelog') || github.event.pull_request.user.login == 'dependabot[bot]' }} - BASE_REF: ${{ github.event.pull_request.base.sha || 'origin/main' }} + BASE_REF: origin/${{ github.base_ref || 'main' }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: From 23f653c6e5621452c429cb5a2fb20cb9b8b03086 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Mon, 26 Oct 2026 18:33:48 -0700 Subject: [PATCH 8/9] [vm-tools] Read startup scripts from the local shared directory Restore the unchanged changelog workflow as requested. --- .changeset/vm-tools.md | 2 +- .github/workflows/changelog.yml | 3 +-- packages/vm-tools/README.md | 2 +- packages/vm-tools/src/commands/daemon.ts | 2 +- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.changeset/vm-tools.md b/.changeset/vm-tools.md index a81cfb4f2b..01e0d6ddee 100644 --- a/.changeset/vm-tools.md +++ b/.changeset/vm-tools.md @@ -2,4 +2,4 @@ '@expo/vm-tools': minor --- -Add a macOS startup script loader that runs startup.sh once per VM boot. +Add a macOS startup script loader that runs `/usr/local/share/vm-tools/startup.sh` once per VM boot. diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index 88939a09f0..66daf52944 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -28,11 +28,10 @@ jobs: contains(github.event.pull_request.labels.*.name, 'no changelog') || github.event.pull_request.user.login == 'dependabot[bot]' }} - BASE_REF: origin/${{ github.base_ref || 'main' }} + BASE_REF: ${{ github.event.pull_request.base.sha || 'origin/main' }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} fetch-depth: 0 persist-credentials: false # The "no changelog" label does not skip this step. diff --git a/packages/vm-tools/README.md b/packages/vm-tools/README.md index cb39d28b68..4516eb35f7 100644 --- a/packages/vm-tools/README.md +++ b/packages/vm-tools/README.md @@ -14,7 +14,7 @@ executable used for installation must remain available. ## Startup script -At login, vm-tools waits for `/Volumes/My Shared Files/expo-startup/startup.sh` and runs it with +At login, vm-tools waits for `/usr/local/share/vm-tools/startup.sh` and runs it with `/bin/bash` once per VM boot. Put the file in the VM or provide it through a shared directory. The script does not need executable permission. diff --git a/packages/vm-tools/src/commands/daemon.ts b/packages/vm-tools/src/commands/daemon.ts index d4640fe6ad..cec582b228 100644 --- a/packages/vm-tools/src/commands/daemon.ts +++ b/packages/vm-tools/src/commands/daemon.ts @@ -6,7 +6,7 @@ import { promisify } from 'node:util'; import { getVmStateDirectory } from '../utils/state'; -const STARTUP_DIRECTORY = '/Volumes/My Shared Files/expo-startup'; +const STARTUP_DIRECTORY = '/usr/local/share/vm-tools'; const STARTUP_SCRIPT = 'startup.sh'; const execFilePromise = promisify(execFile); From 8f4b9b642b9d81c703146ed8949edb9f11f76e4d Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Wed, 28 Oct 2026 15:14:05 -0700 Subject: [PATCH 9/9] [vm-tools] Start at the fixed-group version Co-Authored-By: Claude Opus 5.5 --- packages/vm-tools/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vm-tools/package.json b/packages/vm-tools/package.json index 131f8db897..cfef717a78 100644 --- a/packages/vm-tools/package.json +++ b/packages/vm-tools/package.json @@ -1,6 +1,6 @@ { "name": "@expo/vm-tools", - "version": "25.3.0", + "version": "27.1.0", "description": "VM startup script loader", "bugs": "https://github.com/expo/eas-cli/issues", "license": "BUSL-1.1",