From 60cea609ccc61f55f22c00cb9bafe11a472a246f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 15 Oct 2026 22:14:35 +0200 Subject: [PATCH 1/6] fix(macos): attach to a running app instead of launching a duplicate on open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A macOS app started directly — by a supervisor or a dev script — is not listed by LaunchServices until half a second or so after it spawns. During that window `open -b` sees no instance and starts a second copy with no arguments, which the session then drives instead of the copy the caller owns (#3406). The bundle open now reads the same view `open -b` consults and, when it lists nothing while a process of that bundle is running, waits until LaunchServices names the running copy before running `open` unchanged. An already-listed bundle and a bundle with nothing running reach `open` on the first read; the wait, every read, and the process scan share one budget, and anything the probe cannot answer fails open so an open is never withheld. --- CHANGELOG.md | 1 + .../src/core/__tests__/tool-provider.test.ts | 5 + .../src/os/macos/host-provider.ts | 6 + .../launch-services-registration.test.ts | 579 ++++++++++++++++++ .../os/macos/launch-services-registration.ts | 253 ++++++++ website/docs/docs/commands.md | 1 + 6 files changed, 845 insertions(+) create mode 100644 packages/platform-apple/src/os/macos/launch-services-registration.test.ts create mode 100644 packages/platform-apple/src/os/macos/launch-services-registration.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 41be908fda..e0d4ebfdb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Add `settings mobile-data on|off` for adb-backed Android targets. It switches mobile data through `svc data` and reports the `mobileData` state stored in the global `mobile_data` row; multi-SIM read-back is not covered yet. Every other target refuses it before anything runs, and `--app` is refused because the switch is device-wide. - On iOS, a `fill` that runs under a degraded XCTest snapshot channel now aims at a point, including ref, selector and replay fills. When the runner identifies no text input under that point, it refuses with `NO_TEXT_INPUT_AT_POINT` (`error.details.reason: no_text_input_at_point`, `dispatched: no`) before dispatching its focus tap, and no field changes; when the input lookup itself cannot answer, it refuses with `TEXT_INPUT_PROBE_UNAVAILABLE` (`error.details.reason: text_input_probe_unavailable`). Previously such a fill cleared and typed through the app-level first responder, so it could rewrite a field the caller never named, or report a clear failure after emptying it. A ref or selector fill whose rect centre misses the real field (an outer container, a non-input node) now needs a point inside the input's own rect. +- Opening a macOS app by bundle identifier now waits briefly when the app is already running but not yet registered with LaunchServices — the case when a supervisor or dev script started it directly — and if registration arrives within that bounded window the open attaches to the running copy, so the session drives the app the caller started, with its arguments. A copy still unlisted when the window closes gets the plain `open` as before, which can still duplicate; the wait only bounds the race, it does not close it. Launching an app that is not running costs one extra LaunchServices lookup and one process-table read, plus one bundle-identifier read when app-shaped processes exist. - Limrun iOS snapshots no longer fail when the instance reports a numeric or boolean accessibility value (the system photo picker's tabs report `1`/`0`): the provider seam normalizes variant scalars to text, the way the Apple tree does. Limrun nodes now also publish `selected: true` for nodes carrying the `Selected` trait, so snapshot output and `selected:` selectors can tell a picked photo from an unpicked one. - `settings` refuses a setting the target does not serve before anything changes, with `UNSUPPORTED_OPERATION`, `settings is not supported on this device`, and the target's `error.details.reason` (such as `unsupported-platform-leaf`). This replaces the macOS `INVALID_ARGS` refusal and the Apple `setting-unsupported-on-leaf` reason, and now also covers settings that used to reach the device first, such as Android `reset-keychain` or Apple `animations`. - `settings` refuses, before touching the device, a state its setting does not accept (`Invalid state: …`), a permission action or mode its target does not take (such as `permission deny accessibility`), and a stray extra argument (such as `clear-app-state `). The CLI and the daemon accept the same state spellings, in any casing or surrounding whitespace. diff --git a/packages/platform-apple/src/core/__tests__/tool-provider.test.ts b/packages/platform-apple/src/core/__tests__/tool-provider.test.ts index 88cc233ad1..fac7dcf5e8 100644 --- a/packages/platform-apple/src/core/__tests__/tool-provider.test.ts +++ b/packages/platform-apple/src/core/__tests__/tool-provider.test.ts @@ -177,6 +177,11 @@ test('local Apple tool provider exposes macOS host operations as semantic method const calls: Array<[string, string[], string | undefined]> = []; const provider = createLocalAppleToolProvider({ runCommand: async (cmd, args, options) => { + // The bundle open's LaunchServices registration probe is the seam's own read, asserted in + // host-provider-open.test.ts; this routing test only reports it as listing the bundle. + if (cmd === '/usr/bin/lsappinfo') { + return { exitCode: 0, stdout: 'ASN:0x0-0x1234-"Demo":\n', stderr: '' }; + } calls.push([cmd, args, typeof options?.stdin === 'string' ? options.stdin : undefined]); if (cmd === 'pbpaste') { return { exitCode: 0, stdout: 'copied\r\n', stderr: '' }; diff --git a/packages/platform-apple/src/os/macos/host-provider.ts b/packages/platform-apple/src/os/macos/host-provider.ts index 710fb25a34..6d3338c6c7 100644 --- a/packages/platform-apple/src/os/macos/host-provider.ts +++ b/packages/platform-apple/src/os/macos/host-provider.ts @@ -18,6 +18,12 @@ export function createLocalAppleMacOsHostProvider( ): AppleMacOsHostProvider { return { openBundle: async (bundleId, url) => { + // Loaded on the first bundle open, not when this provider is constructed: the probe's host-kit + // reads stay off the Apple façades' eager closure (ADR 0019), and an open of an already-listed + // app is the common path that never needs them until `open` actually runs. + const { waitForLaunchServicesRegistration } = + await import('./launch-services-registration.ts'); + await waitForLaunchServicesRegistration(runCommand, bundleId); await runCommand('open', buildMacOpenArgs(bundleId, url)); }, openTarget: async (target) => { diff --git a/packages/platform-apple/src/os/macos/launch-services-registration.test.ts b/packages/platform-apple/src/os/macos/launch-services-registration.test.ts new file mode 100644 index 0000000000..a3ad150646 --- /dev/null +++ b/packages/platform-apple/src/os/macos/launch-services-registration.test.ts @@ -0,0 +1,579 @@ +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import { AppError } from '@agent-device/kernel/errors'; +import type { ExecResult } from '@agent-device/host-kit/command'; +import type { HostProcessInfo } from '@agent-device/host-kit/process'; + +vi.mock('node:timers/promises', () => ({ setTimeout: vi.fn(async () => undefined) })); +vi.mock('@agent-device/host-kit/process', async (importOriginal) => ({ + ...(await importOriginal()), + listHostProcesses: vi.fn(async () => processTable.rows), +})); +vi.mock('@agent-device/host-kit/diagnostics', () => ({ emitDiagnostic: vi.fn() })); + +import { setTimeout as sleep } from 'node:timers/promises'; +import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; +import { listHostProcesses } from '@agent-device/host-kit/process'; +import { createLocalAppleMacOsHostProvider } from './host-provider.ts'; +import type { AppleToolCommandExecutor } from '../../core/tool-provider-types.ts'; + +const BUNDLE_ID = 'com.example.demo'; +const DEMO_BUNDLE = '/Users/dev/DerivedData/Demo.app'; +const DEMO_PLIST = `${DEMO_BUNDLE}/Contents/Info.plist`; +const GHOST_BUNDLE = '/Applications/Ghost.app'; +const GHOST_PLIST = `${GHOST_BUNDLE}/Contents/Info.plist`; +/** A real bundle directory may contain the `: ` that plutil puts between a name and its message. */ +const COLON_BUNDLE = '/Applications/My App: Pro.app'; +const COLON_PLIST = `${COLON_BUNDLE}/Contents/Info.plist`; +const LAUNCH_SERVICES_TOOL = '/usr/bin/lsappinfo'; +const LAUNCH_SERVICES_READ_CAP_MS = 1_000; +const BUNDLE_ID_CAP_MS = 1_500; +const PROCESS_TABLE_CAP_MS = 2_000; + +/** `lsappinfo find` prints every instance it lists as an `ASN:` token, space-separated on one line. */ +const REGISTERED = 'ASN:0x0-0x1234-"Demo": ASN:0x0-0x1244-"Demo":\n'; + +const processTable = vi.hoisted(() => ({ rows: [] as unknown[] })); + +type RecordedCall = Readonly<{ cmd: string; args: string[]; timeoutMs: number | undefined }>; + +type Stub = Partial | Error; + +/** + * The host tools one bundle open reads — `lsappinfo`, `plutil`, and `open` itself — answered per + * call, with every call and the budget it was given recorded in order. An `Error` answer throws, the + * way a missing tool throws at the real seam. The process table is the separate `host-kit` seam this + * module reads through. + */ +function fakeMacHost(respond: (call: RecordedCall, index: number) => Stub = () => ({})): { + runCommand: AppleToolCommandExecutor; + calls: RecordedCall[]; + transcript(): Array<{ cmd: string; args: string[] }>; +} { + const calls: RecordedCall[] = []; + const runCommand: AppleToolCommandExecutor = async (cmd, args, options) => { + const call = { cmd, args, timeoutMs: options?.timeoutMs }; + const index = calls.push(call) - 1; + const answer = respond(call, index); + if (answer instanceof Error) throw answer; + return { stdout: '', stderr: '', exitCode: 0, ...answer }; + }; + return { + runCommand, + calls, + /** The calls without their budget, for an expectation that is only about which tools ran. */ + transcript: () => calls.map(({ cmd, args }) => ({ cmd, args })), + }; +} + +/** Two copies of the same bundle plus an unrelated app, none of them LaunchServices-listed. */ +function runningCopies(): void { + processTable.rows = [ + { pid: 1, command: '/sbin/launchd' }, + { pid: 4242, command: `${DEMO_BUNDLE}/Contents/MacOS/Demo -DevFlag` }, + { pid: 4243, command: `${DEMO_BUNDLE}/Contents/MacOS/Demo` }, + { pid: 4300, command: `${GHOST_BUNDLE}/Contents/MacOS/Ghost` }, + ] satisfies HostProcessInfo[]; +} + +/** The opened bundle's copy plus a copy of a bundle whose directory name contains `": "`. */ +function runningCopiesIncludingColonBundle(): void { + processTable.rows = [ + { pid: 4242, command: `${DEMO_BUNDLE}/Contents/MacOS/Demo -DevFlag` }, + { pid: 4400, command: `${COLON_BUNDLE}/Contents/MacOS/My App: Pro` }, + // A shell one-liner quoting some bundle's executable path looks exactly like a bundle process by + // path shape, so it reaches the batch as a candidate whose plist does not exist. This is the + // row that makes an unreadable-bundle name show up on stderr on a real host. + { pid: 4500, command: `/bin/zsh -c pkill -f ${DEMO_BUNDLE}/Contents/MacOS/Demo` }, + ] satisfies HostProcessInfo[]; +} + +function noRunningApps(): void { + processTable.rows = [{ pid: 1, command: '/sbin/launchd' }]; +} + +function openBundle(runCommand: AppleToolCommandExecutor): Promise { + return createLocalAppleMacOsHostProvider(runCommand, async () => null).openBundle(BUNDLE_ID); +} + +function registrationReads(calls: readonly RecordedCall[]): RecordedCall[] { + return calls.filter((call) => call.cmd === LAUNCH_SERVICES_TOOL); +} + +const mockSleep = vi.mocked(sleep); +const mockEmitDiagnostic = vi.mocked(emitDiagnostic); + +beforeEach(() => { + processTable.rows = []; + // The table seam's call history must not decide a later test's `not.toHaveBeenCalled()`: order + // safety comes from this reset, not from the luck of the run order. + vi.mocked(listHostProcesses).mockClear(); +}); + +afterEach(() => { + mockSleep.mockClear(); + mockEmitDiagnostic.mockClear(); +}); + +test('openBundle runs open at once when LaunchServices already lists an instance', async () => { + const { runCommand, calls, transcript } = fakeMacHost((call) => + call.cmd === LAUNCH_SERVICES_TOOL ? { stdout: REGISTERED } : {}, + ); + + await openBundle(runCommand); + + // Attaching to a listed instance is `open -b`'s own job, so no process-table read and no poll + // stands between this open and `open`. + expect(transcript()).toEqual([ + { cmd: LAUNCH_SERVICES_TOOL, args: ['find', `bundleid=${BUNDLE_ID}`] }, + { cmd: 'open', args: ['-b', BUNDLE_ID] }, + ]); + expect(listHostProcesses).not.toHaveBeenCalled(); + expect(mockSleep).not.toHaveBeenCalled(); + // One read of the registration view, inside its own cap. + expect(registrationReads(calls)[0]?.timeoutMs).toBeLessThanOrEqual(LAUNCH_SERVICES_READ_CAP_MS); +}); + +test('openBundle runs open at once when no process of the opened bundle runs', async () => { + noRunningApps(); + const { runCommand, transcript } = fakeMacHost(); + + await openBundle(runCommand); + + // The first-launch case: nothing is listed, the process table holds no bundle whose identifier + // matches, so this open is the launch and no plist of a candidate is read to prove a copy exists. + expect(transcript()).toEqual([ + { cmd: LAUNCH_SERVICES_TOOL, args: ['find', `bundleid=${BUNDLE_ID}`] }, + { cmd: 'open', args: ['-b', BUNDLE_ID] }, + ]); + expect(listHostProcesses).toHaveBeenCalledWith({ timeoutMs: PROCESS_TABLE_CAP_MS }); + expect(mockSleep).not.toHaveBeenCalled(); +}); + +test('openBundle waits for LaunchServices to list a copy that runs unlisted', async () => { + runningCopies(); + let registrationReadsDone = 0; + const { runCommand, calls, transcript } = fakeMacHost((call) => { + if (call.cmd === LAUNCH_SERVICES_TOOL) { + registrationReadsDone += 1; + // The copy is listed on the first read of its own pid in the second poll. + return registrationReadsDone > 3 ? { stdout: REGISTERED } : {}; + } + if (call.cmd === 'plutil') { + // One call answers every candidate bundle: a row per readable plist, and Ghost's unreadable + // plist named on stderr and absent from stdout. + expect(call.args).toEqual(['-extract', 'CFBundleIdentifier', 'raw', DEMO_PLIST, GHOST_PLIST]); + return { + stdout: 'com.example.demo\n', + stderr: `${GHOST_PLIST}: Could not extract value, error: No value at that key path\n`, + exitCode: 1, + }; + } + return {}; + }); + + await openBundle(runCommand); + + // Every wait is pinned to the copies the process table found — both copies of the opened bundle, + // so the wait ends on whichever one the registration names — and never on the unrelated app. + expect(transcript().slice(0, -1)).toEqual([ + { cmd: LAUNCH_SERVICES_TOOL, args: ['find', `bundleid=${BUNDLE_ID}`] }, + { cmd: 'plutil', args: ['-extract', 'CFBundleIdentifier', 'raw', DEMO_PLIST, GHOST_PLIST] }, + { cmd: LAUNCH_SERVICES_TOOL, args: ['find', `bundleid=${BUNDLE_ID}`, 'pid=4242'] }, + { cmd: LAUNCH_SERVICES_TOOL, args: ['find', `bundleid=${BUNDLE_ID}`, 'pid=4243'] }, + { cmd: LAUNCH_SERVICES_TOOL, args: ['find', `bundleid=${BUNDLE_ID}`, 'pid=4242'] }, + ]); + expect(mockSleep).toHaveBeenCalledTimes(2); + expect(transcript().at(-1)).toEqual({ cmd: 'open', args: ['-b', BUNDLE_ID] }); + // Each probe read carries a budget: the registration reads their own cap, the identifier read + // its larger one, and neither spends more than that. + for (const read of registrationReads(calls)) { + expect(read.timeoutMs).toBeLessThanOrEqual(LAUNCH_SERVICES_READ_CAP_MS); + } + expect(calls.find((call) => call.cmd === 'plutil')?.timeoutMs).toBeLessThanOrEqual( + BUNDLE_ID_CAP_MS, + ); +}); + +test('openBundle never waits for a running copy of some other bundle', async () => { + runningCopies(); + const { runCommand, transcript } = fakeMacHost((call) => { + if (call.cmd === 'plutil') { + // Both candidates belong to other bundles, so this open really is a launch. The empty row is + // a plist that carries the key with no value: plutil prints it with no stderr, so the row + // count still lines up and the demo copy keeps its own identifier. + return { stdout: '\ncom.ghost.app\n' }; + } + return {}; + }); + + await openBundle(runCommand); + + expect(transcript()).toEqual([ + { cmd: LAUNCH_SERVICES_TOOL, args: ['find', `bundleid=${BUNDLE_ID}`] }, + { cmd: 'plutil', args: ['-extract', 'CFBundleIdentifier', 'raw', DEMO_PLIST, GHOST_PLIST] }, + { cmd: 'open', args: ['-b', BUNDLE_ID] }, + ]); + expect(mockSleep).not.toHaveBeenCalled(); +}); + +test('openBundle abandons an unusable bundle identifier read instead of guessing a copy', async () => { + runningCopies(); + const { runCommand, calls } = fakeMacHost((call) => { + if (call.cmd === 'plutil') { + // Neither plist is named as unreadable, yet only one row comes back for two paths: pairing + // would shift one bundle's identifier onto another bundle's process, so no copy is inferred. + return { stdout: 'com.example.demo\n', exitCode: 1 }; + } + return {}; + }); + + await openBundle(runCommand); + + expect(calls.at(-1)).toEqual({ cmd: 'open', args: ['-b', BUNDLE_ID] }); + expect(registrationReads(calls)).toHaveLength(1); + expect(mockSleep).not.toHaveBeenCalled(); +}); + +test('openBundle pairs rows with paths when an unreadable bundle name itself contains ": "', async () => { + runningCopiesIncludingColonBundle(); + const { runCommand, calls } = fakeMacHost((call) => { + if (call.cmd === 'plutil') { + // Every candidate but the opened bundle is unreadable, and the unreadable names are reported + // the way the tool does it: the path unquoted, then `: `, then a message that contains `": "` + // of its own. Pairing on the first `": "` would name a prefix of the real path, leave the + // plist counted as readable, and discard this whole read - which is the whole wait, skipped. + expect(call.args).toEqual([ + '-extract', + 'CFBundleIdentifier', + 'raw', + DEMO_PLIST, + COLON_PLIST, + '/bin/zsh -c pkill -f /Users/dev/DerivedData/Demo.app/Contents/Info.plist', + ]); + return { + stdout: 'com.example.demo\n', + stderr: + `${COLON_PLIST}: Could not extract value, error: No value at that key ` + + 'path, expected: a string\n' + + '/bin/zsh -c pkill -f /Users/dev/DerivedData/Demo.app/Contents/Info.plist: ' + + 'The file "Info.plist" doesn\u2019t exist.\n', + exitCode: 1, + }; + } + if (call.cmd === LAUNCH_SERVICES_TOOL && call.args.some((arg) => arg.startsWith('pid='))) { + return { stdout: REGISTERED }; + } + return {}; + }); + + await openBundle(runCommand); + + // The copy is still found, so the wait runs and ends on its registration instead of opening blind. + expect(registrationReads(calls).slice(1)).toEqual([ + { + cmd: LAUNCH_SERVICES_TOOL, + args: ['find', `bundleid=${BUNDLE_ID}`, 'pid=4242'], + timeoutMs: expect.any(Number), + }, + ]); + expect(mockSleep).toHaveBeenCalledTimes(1); + expect(calls.at(-1)).toEqual({ cmd: 'open', args: ['-b', BUNDLE_ID] }); +}); + +test('openBundle still opens a copy LaunchServices never lists', async () => { + runningCopies(); + const { runCommand, calls } = fakeMacHost((call) => { + if (call.cmd === 'plutil') return { stdout: 'com.example.demo\ncom.ghost.app\n' }; + return {}; + }); + + await openBundle(runCommand); + + // Failing open is the only safe answer to a registration that never lands: refusing the open would + // break every caller whose app registers late, and a wait can only ever delay a duplicate, never + // cause one. + expect(calls.at(-1)).toEqual({ cmd: 'open', args: ['-b', BUNDLE_ID] }); + expect(mockSleep).toHaveBeenCalledTimes(10); +}); + +test('openBundle ends the wait on a registration read that fails rather than reopening early', async () => { + runningCopies(); + let polls = 0; + const { runCommand, calls } = fakeMacHost((call) => { + if (call.cmd === 'plutil') return { stdout: 'com.example.demo\ncom.ghost.app\n' }; + if (call.cmd === '/usr/bin/lsappinfo' && call.args.some((arg) => arg.startsWith('pid='))) { + polls += 1; + // A transient failure mid-wait must not be read as "registered": that is exactly the moment + // an `open -b` would duplicate. The bound, not the failure, ends this wait. + return polls === 3 ? { exitCode: 1, stderr: 'lsappinfo: transient' } : {}; + } + return {}; + }); + + await openBundle(runCommand); + + expect(mockSleep).toHaveBeenCalledTimes(10); + expect(calls.at(-1)).toEqual({ cmd: 'open', args: ['-b', BUNDLE_ID] }); +}); + +test('openBundle treats a failing first registration read as an instance to attach to', async () => { + const { runCommand, transcript } = fakeMacHost((call) => + call.cmd === LAUNCH_SERVICES_TOOL + ? { exitCode: 1, stderr: 'lsappinfo: launch services error' } + : {}, + ); + + await openBundle(runCommand); + + // Nothing was learned, so nothing is inferred: no process-table scan, no wait. + expect(transcript()).toEqual([ + { cmd: LAUNCH_SERVICES_TOOL, args: ['find', `bundleid=${BUNDLE_ID}`] }, + { cmd: 'open', args: ['-b', BUNDLE_ID] }, + ]); + expect(listHostProcesses).not.toHaveBeenCalled(); + expect(mockSleep).not.toHaveBeenCalled(); +}); + +test('openBundle reports a process table that answers nothing rather than reading it as empty', async () => { + processTable.rows = []; + const { runCommand, transcript } = fakeMacHost((call) => + call.cmd === 'plutil' ? { stdout: 'com.example.demo\n' } : {}, + ); + + await openBundle(runCommand); + + // A macOS host always has processes, so an empty answer is the read failing. Inferring "nothing is + // running" from it would skip the wait and reproduce the duplicate this seam exists to prevent. + expect(transcript()).toEqual([ + { cmd: LAUNCH_SERVICES_TOOL, args: ['find', `bundleid=${BUNDLE_ID}`] }, + { cmd: 'open', args: ['-b', BUNDLE_ID] }, + ]); + expect(mockEmitDiagnostic).toHaveBeenCalledWith( + expect.objectContaining({ + level: 'warn', + phase: 'macos_launch_services_process_table_unreadable', + data: { bundleId: BUNDLE_ID }, + }), + ); + expect(mockSleep).not.toHaveBeenCalled(); +}); + +test('openBundle spends its budget on reads rather than on a wait with nothing left to read', async () => { + vi.useFakeTimers(); + runningCopies(); + const { runCommand, calls, transcript } = fakeMacHost((call) => { + if (call.cmd === 'plutil') { + // A slow scan on a loaded host: by the time it answers, the probe's shared budget is nearly + // gone. Spending the remainder on a 200 ms sleep and a read killed at 100 ms would spawn tools + // that cannot answer, so the wait is left alone and the open runs. + vi.advanceTimersByTime(4_900); + return { stdout: 'com.example.demo\ncom.ghost.app\n' }; + } + return {}; + }); + + await openBundle(runCommand); + vi.useRealTimers(); + + expect(transcript()).toEqual([ + { cmd: LAUNCH_SERVICES_TOOL, args: ['find', `bundleid=${BUNDLE_ID}`] }, + { cmd: 'plutil', args: ['-extract', 'CFBundleIdentifier', 'raw', DEMO_PLIST, GHOST_PLIST] }, + { cmd: 'open', args: ['-b', BUNDLE_ID] }, + ]); + expect(registrationReads(calls)).toHaveLength(1); + expect(mockSleep).not.toHaveBeenCalled(); +}); + +test('openBundle arms every probe read with only the budget the deadline still allows', async () => { + vi.useFakeTimers(); + runningCopies(); + const { runCommand, calls } = fakeMacHost((call) => { + // A first read that is slow but still answers inside its own cap. What is left of the shared + // budget after it is smaller than the caps of the two reads that follow, so each of those has to + // be armed with the remainder rather than with its own cap: a read armed above what is left could + // run past the budget that bounds this probe. + if (call.cmd === LAUNCH_SERVICES_TOOL) { + vi.advanceTimersByTime(3_600); + return {}; + } + // Neither candidate is the opened bundle, so the wait never starts and the reads stay two. + return call.cmd === 'plutil' ? { stdout: '\ncom.ghost.app\n' } : {}; + }); + + await openBundle(runCommand); + vi.useRealTimers(); + + expect(calls.map(({ cmd, timeoutMs }) => ({ cmd, timeoutMs }))).toEqual([ + { cmd: LAUNCH_SERVICES_TOOL, timeoutMs: LAUNCH_SERVICES_READ_CAP_MS }, + { cmd: 'plutil', timeoutMs: 1_400 }, + { cmd: 'open', timeoutMs: undefined }, + ]); + // The process-table read is the other seam this probe arms, and it is the one whose cap is largest. + expect(listHostProcesses).toHaveBeenCalledWith({ timeoutMs: 1_400 }); + expect(mockSleep).not.toHaveBeenCalled(); +}); + +test('openBundle skips a read whose share of the budget falls below what a tool can use', async () => { + vi.useFakeTimers(); + runningCopies(); + const { runCommand, transcript } = fakeMacHost((call) => { + // Only 50 ms of the shared budget is left. Arming a read with it would spawn a tool that cannot + // answer in time, and a zero timeout turns into no timer at all, which is how a probe that is + // meant to be bounded would stop bounding itself. + if (call.cmd === LAUNCH_SERVICES_TOOL) { + vi.advanceTimersByTime(4_950); + return {}; + } + return call.cmd === 'plutil' ? { stdout: 'com.example.demo\ncom.ghost.app\n' } : {}; + }); + + await openBundle(runCommand); + vi.useRealTimers(); + + expect(transcript()).toEqual([ + { cmd: LAUNCH_SERVICES_TOOL, args: ['find', `bundleid=${BUNDLE_ID}`] }, + { cmd: 'open', args: ['-b', BUNDLE_ID] }, + ]); + expect(listHostProcesses).not.toHaveBeenCalled(); + expect(mockSleep).not.toHaveBeenCalled(); + expect(mockEmitDiagnostic).not.toHaveBeenCalled(); +}); + +test('openBundle reads identifiers only for candidates that are app bundles', async () => { + processTable.rows = [ + ...([ + { pid: 4242, command: `${DEMO_BUNDLE}/Contents/MacOS/Demo -DevFlag` }, + { pid: 4300, command: `${GHOST_BUNDLE}/Contents/MacOS/Ghost` }, + // An app extension and a driver extension are bundles of their own, and most rows carrying + // `/Contents/MacOS/` on a real host are these and XPC services, whose identifiers match no app + // anyone opens. Keeping them out is what keeps the batch the size of the running apps. + { + pid: 4600, + command: `${DEMO_BUNDLE}/Contents/PlugIns/Widget.appex/Contents/MacOS/Widget`, + }, + { pid: 4700, command: '/opt/dext/driver.dext/Contents/MacOS/driver' }, + ] satisfies HostProcessInfo[]), + ]; + const { runCommand, transcript } = fakeMacHost((call) => + call.cmd === 'plutil' ? { stdout: '\n\n' } : {}, + ); + + await openBundle(runCommand); + + expect(transcript()).toEqual([ + { cmd: LAUNCH_SERVICES_TOOL, args: ['find', `bundleid=${BUNDLE_ID}`] }, + { + cmd: 'plutil', + args: ['-extract', 'CFBundleIdentifier', 'raw', DEMO_PLIST, GHOST_PLIST], + }, + { cmd: 'open', args: ['-b', BUNDLE_ID] }, + ]); + expect(mockSleep).not.toHaveBeenCalled(); +}); + +test('openBundle keeps the wait when the last candidate reports an empty identifier', async () => { + // `plutil` prints an empty row for a plist that carries the key with no value, so the trailing + // newline of that row is data: the two rows still pair with the two candidates and the opened + // bundle's copy is among them. Dropping every trailing newline instead would lose a row, discard + // the read as unpairable, and hand the duplicate back. + runningCopies(); + const { runCommand, transcript } = fakeMacHost((call) => + call.cmd === 'plutil' ? { stdout: 'com.example.demo\n\n' } : {}, + ); + + await openBundle(runCommand); + + expect(transcript().at(-1)).toEqual({ cmd: 'open', args: ['-b', BUNDLE_ID] }); + expect(mockSleep).toHaveBeenCalledTimes(10); +}); + +test('openBundle leaves the open alone when the first registration read is killed at its budget', async () => { + runningCopies(); + const { runCommand, transcript } = fakeMacHost((call) => { + if (call.cmd === LAUNCH_SERVICES_TOOL && !call.args.some((arg) => arg.startsWith('pid='))) { + // A slow first read says nothing about whether an instance is listed, so unlike a mid-wait + // timeout it must not be read as "nothing is listed" either: that would scan the process table + // of a host that is already slow and start a wait on evidence the read never gave. + return new AppError('COMMAND_FAILED', 'lsappinfo timed out after 1000ms', { + timeoutMs: 1_000, + }); + } + return {}; + }); + + await openBundle(runCommand); + + expect(transcript()).toEqual([ + { cmd: LAUNCH_SERVICES_TOOL, args: ['find', `bundleid=${BUNDLE_ID}`] }, + { cmd: 'open', args: ['-b', BUNDLE_ID] }, + ]); + expect(listHostProcesses).not.toHaveBeenCalled(); + expect(mockSleep).not.toHaveBeenCalled(); + expect(mockEmitDiagnostic).not.toHaveBeenCalled(); +}); + +test('openBundle keeps waiting when one registration read is killed at its own budget', async () => { + runningCopies(); + let killed = 0; + const { runCommand, calls } = fakeMacHost((call) => { + if (call.cmd === 'plutil') return { stdout: 'com.example.demo\ncom.ghost.app\n' }; + if (call.cmd === LAUNCH_SERVICES_TOOL && call.args.some((arg) => arg.startsWith('pid='))) { + killed += 1; + // The exec seam rejects a command it killed at its own `timeoutMs`, even one allowed to fail. + // Reading that as "give up on the probe" would open the bundle at the first slow host read, + // which is the moment an `open -b` duplicates. + if (killed === 2) { + return new AppError('COMMAND_FAILED', 'lsappinfo timed out after 100ms', { + timeoutMs: 100, + }); + } + return {}; + } + return {}; + }); + + await openBundle(runCommand); + + expect(mockSleep).toHaveBeenCalledTimes(10); + expect(calls.at(-1)).toEqual({ cmd: 'open', args: ['-b', BUNDLE_ID] }); + expect(mockEmitDiagnostic).not.toHaveBeenCalled(); +}); + +test('openBundle opens unchanged and reports a probe whose registration tool cannot spawn', async () => { + const { runCommand, calls } = fakeMacHost((call) => + call.cmd === LAUNCH_SERVICES_TOOL + ? new AppError('TOOL_MISSING', 'Required macOS tool not found: /usr/bin/lsappinfo') + : {}, + ); + + await openBundle(runCommand); + + // The probe is an extra read on someone else's launch, so a host that cannot answer it at all gets + // the open it would have had without the probe — with a diagnostic naming why, by typed code. + expect(calls.at(-1)).toEqual({ cmd: 'open', args: ['-b', BUNDLE_ID] }); + expect(mockEmitDiagnostic).toHaveBeenCalledWith( + expect.objectContaining({ + level: 'warn', + phase: 'macos_launch_services_registration_probe_failed', + data: expect.objectContaining({ bundleId: BUNDLE_ID, reason: 'TOOL_MISSING' }), + }), + ); +}); + +test('openBundle reports a probe that failed for a reason no typed code describes', async () => { + const { runCommand, calls } = fakeMacHost((call) => + call.cmd === LAUNCH_SERVICES_TOOL ? new Error('lsappinfo killed by the host') : {}, + ); + + await openBundle(runCommand); + + // The reason is keyed on a typed code, never on the error's text, so what is left when an error + // carries no code is its own name rather than a guess scraped out of the message. + expect(calls.at(-1)).toEqual({ cmd: 'open', args: ['-b', BUNDLE_ID] }); + expect(mockEmitDiagnostic).toHaveBeenCalledWith( + expect.objectContaining({ + phase: 'macos_launch_services_registration_probe_failed', + data: { bundleId: BUNDLE_ID, reason: 'probe_read_failed' }, + }), + ); +}); diff --git a/packages/platform-apple/src/os/macos/launch-services-registration.ts b/packages/platform-apple/src/os/macos/launch-services-registration.ts new file mode 100644 index 0000000000..9e9af2d56e --- /dev/null +++ b/packages/platform-apple/src/os/macos/launch-services-registration.ts @@ -0,0 +1,253 @@ +import path from 'node:path'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { AppError } from '@agent-device/kernel/errors'; +import { + isCommandTimeoutError, + withoutCommandExecutorOverride, + type ExecResult, +} from '@agent-device/host-kit/command'; +import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; +import { listHostProcesses } from '@agent-device/host-kit/process'; +import { Deadline } from '@agent-device/host-kit/retry'; +import type { AppleToolCommandExecutor } from '../../core/tool-provider-types.ts'; + +const LAUNCH_SERVICES_POLL_MS = 200; +const LAUNCH_SERVICES_MAX_POLLS = 10; +const LAUNCH_SERVICES_READ_TIMEOUT_MS = 1_000; +const PROCESS_TABLE_TIMEOUT_MS = 2_000; +const BUNDLE_ID_READ_TIMEOUT_MS = 1_500; +/** + * Ceiling for the whole probe, shared by every read below. AGENTS.md asks an optional probe's + * budget to stay shorter than the operation it precedes, and this one exceeds a healthy `open -b` + * (~70 ms measured) on purpose — see the PR #3707 discussion: the operation this probe guards is + * the open REQUEST, which runs inside the command registry's 90 s envelope, and the probe's own + * worst case is bounded at this ceiling with every read failing open. The measured typical cost + * is what makes that trade proportionate: one `lsappinfo find` (12-40 ms) plus one process-table + * read (~0.2-0.3 s on a healthy host), and the 5 s ceiling is only ever reached when a host tool + * hangs — the open then proceeds late rather than being withheld. + */ +const LAUNCH_SERVICES_PROBE_BUDGET_MS = 5_000; +/** + * Below this remaining budget a read is worth skipping rather than spawning with a token amount. It + * is also what keeps every read bounded: the exec seam turns a zero timeout into no timer at all, so + * a read armed with what is left of the budget after the floor would run unbounded. + */ +const MIN_PROBE_READ_MS = 100; +const LAUNCH_SERVICES_TOOL = '/usr/bin/lsappinfo'; +const BUNDLE_ID_TOOL = 'plutil'; +const BUNDLE_ID_KEY = 'CFBundleIdentifier'; +const APP_EXECUTABLE_MARKER = '/Contents/MacOS/'; +const APP_BUNDLE_SUFFIX = '.app'; +const APP_BUNDLE_INFO_PLIST = 'Contents/Info.plist'; +const LSAPPINFO_ASN = /ASN:/; + +/** + * Waits out the LaunchServices registration race so an open does not start a second copy of an app + * whose running copy LaunchServices has not listed yet (#3406). + * + * A copy started directly — by a supervisor or a dev script — is only listed by LaunchServices + * about half a second after it spawns. While it is unlisted, `open -b` sees no instance and launches + * a second one with no arguments, which the session then drives instead of the copy the caller + * owns. An open whose bundle LaunchServices already lists reaches `open` on that single read; an + * open with no running copy pays one `lsappinfo find` and one process-table read (plus a + * bundle-identifier read only when app-shaped candidates exist) to learn that; only a copy that + * runs while unlisted is waited for, and only until LaunchServices lists it, since `open -b` then + * attaches instead of launching. Every read shares one budget, and the wait itself stays well + * inside it: + * + * - `LAUNCH_SERVICES_PROBE_BUDGET_MS` bounds every read below, each read also taking its own + * smaller cap so a single hung tool cannot consume the budget alone; + * - `LAUNCH_SERVICES_MAX_POLLS` x `LAUNCH_SERVICES_POLL_MS` bounds the wait, sized for the ~0.5 s + * race measured on host Macs and far below the 90 s request envelope an open runs inside. + * + * A copy still unlisted when either bound runs out is opened anyway: the wait can only delay a + * duplicate, never cause one, and never withhold the open. + */ +export async function waitForLaunchServicesRegistration( + runCommand: AppleToolCommandExecutor, + bundleId: string, +): Promise { + const deadline = Deadline.fromTimeoutMs(LAUNCH_SERVICES_PROBE_BUDGET_MS); + try { + const listed = await readsLaunchServicesInstance(runCommand, bundleId, deadline); + // Only an explicit "nothing is listed" is worth probing the process table for; a read that + // cannot answer leaves this open the way it was before the check existed. + if (listed !== false) return; + const unlistedPids = await findUnlistedInstancePids(runCommand, bundleId, deadline); + if (unlistedPids.length === 0) return; + for (let poll = 0; poll < LAUNCH_SERVICES_MAX_POLLS; poll += 1) { + if (deadline.remainingMs() < LAUNCH_SERVICES_POLL_MS + MIN_PROBE_READ_MS) return; + await sleep(LAUNCH_SERVICES_POLL_MS); + for (const pid of unlistedPids) { + // A copy LaunchServices has come to list is the copy `open -b` will attach to, whichever of + // the running copies it is. A read that answers nothing keeps the wait on its own bound + // rather than being read as a registration, which is what would hand the duplicate back. A + // read that cannot run at all ends the probe, which opens the bundle the way an unreadable + // host always did. + if ((await readsLaunchServicesInstance(runCommand, bundleId, deadline, pid)) === true) { + return; + } + } + } + } catch (error) { + // The probe is an extra read on someone else's app launch, so a host that cannot answer it at + // all gets the open it would have had without the probe. The diagnostic is the only trace of + // why a duplicate still happened. + emitDiagnostic({ + level: 'warn', + phase: 'macos_launch_services_registration_probe_failed', + data: { + bundleId, + reason: error instanceof AppError ? error.code : 'probe_read_failed', + }, + }); + } +} + +/** + * Whether LaunchServices lists a running instance of `bundleId`, optionally one exact process, or + * `undefined` when the read failed. It lists nothing for an app that is not running and for a copy + * still registering, which is what makes it the seam's only view of what `open -b` would attach to. + * Failing to answer is its own answer: the probe's first read treats it as an instance to attach to, + * so the open proceeds exactly as it did before the check existed, and only the wait for a process + * the caller holds refuses to read it as one, because that wait ends on its own bound. + */ +async function readsLaunchServicesInstance( + runCommand: AppleToolCommandExecutor, + bundleId: string, + deadline: Deadline, + pid?: number, +): Promise { + const timeoutMs = probeBudgetMs(deadline, LAUNCH_SERVICES_READ_TIMEOUT_MS); + if (timeoutMs === undefined) return undefined; + let result: ExecResult; + try { + result = await runCommand( + LAUNCH_SERVICES_TOOL, + ['find', `bundleid=${bundleId}`, ...(pid === undefined ? [] : [`pid=${pid}`])], + { allowFailure: true, timeoutMs }, + ); + } catch (error) { + // A read killed at its own budget answers nothing, the way a non-zero exit does. Only a tool that + // cannot run at all is worth ending the whole probe for. + if (isCommandTimeoutError(error)) return undefined; + throw error; + } + if (result.exitCode !== 0) return undefined; + // The tool prints every instance it lists as an `ASN:` token, space-separated on one line. + return LSAPPINFO_ASN.test(result.stdout); +} + +/** + * The processes running `bundleId` that LaunchServices does not list. `open -b` attaches to + * whichever copy the registration eventually names, so every running copy of the bundle is watched + * and any one of them being listed ends the wait. + */ +async function findUnlistedInstancePids( + runCommand: AppleToolCommandExecutor, + bundleId: string, + deadline: Deadline, +): Promise { + const timeoutMs = probeBudgetMs(deadline, PROCESS_TABLE_TIMEOUT_MS); + if (timeoutMs === undefined) return []; + const processes = await withoutCommandExecutorOverride( + async () => await listHostProcesses({ timeoutMs }), + ); + if (processes.length === 0) { + // A macOS host always has a process table, so no rows at all is the read failing rather than a + // host running nothing. The open proceeds without this probe knowing anything, so the reason is + // worth leaving behind. + emitDiagnostic({ + level: 'warn', + phase: 'macos_launch_services_process_table_unreadable', + data: { bundleId }, + }); + return []; + } + const pidsByBundle = new Map(); + for (const processInfo of processes) { + const bundlePath = macAppBundlePath(processInfo.command); + if (bundlePath === undefined) continue; + const pids = pidsByBundle.get(bundlePath); + if (pids) pids.push(processInfo.pid); + else pidsByBundle.set(bundlePath, [processInfo.pid]); + } + if (pidsByBundle.size === 0) return []; + const bundlePaths = Array.from(pidsByBundle.keys()); + const bundleIds = await readAppBundleIds( + runCommand, + bundlePaths.map((bundlePath) => path.join(bundlePath, APP_BUNDLE_INFO_PLIST)), + deadline, + ); + return bundlePaths + .filter( + (bundlePath) => + bundleIds.get(path.join(bundlePath, APP_BUNDLE_INFO_PLIST))?.trim() === bundleId, + ) + .flatMap((bundlePath) => pidsByBundle.get(bundlePath)!); +} + +/** + * The app bundle an executable path like `/Applications/App.app/Contents/MacOS/App` belongs to, or + * none. Requiring the prefix to be a bundle rather than merely a path containing that directory + * keeps the candidates to the shape `open -b` can launch: most rows carrying `/Contents/MacOS/` are + * XPC services, app extensions, and driver bundles, whose identifiers match no app anyone opens. + */ +function macAppBundlePath(command: string): string | undefined { + const marker = command.indexOf(APP_EXECUTABLE_MARKER); + if (marker <= 0) return undefined; + const bundlePath = command.slice(0, marker); + return bundlePath.endsWith(APP_BUNDLE_SUFFIX) ? bundlePath : undefined; +} + +/** + * The `CFBundleIdentifier` of each given Info.plist, read in one `plutil` call. The tool prints one + * row per readable plist in argument order — an empty row for a plist carrying the key with no + * value — and names every unreadable plist on stderr as `: `, with the path + * unquoted, so a plist is named only by a line that starts with the path it was asked about. When + * the counts still disagree the read is unusable: it reports no identifiers rather than shifting one + * bundle's identifier onto another bundle's process, and it never guesses which plist a row belongs + * to. + */ +async function readAppBundleIds( + runCommand: AppleToolCommandExecutor, + plistPaths: readonly string[], + deadline: Deadline, +): Promise> { + const timeoutMs = probeBudgetMs(deadline, BUNDLE_ID_READ_TIMEOUT_MS); + if (timeoutMs === undefined) return new Map(); + const result = await runCommand( + BUNDLE_ID_TOOL, + ['-extract', BUNDLE_ID_KEY, 'raw', ...plistPaths], + { allowFailure: true, timeoutMs }, + ); + const rows = stripTrailingNewline(result.stdout).split('\n'); + const readable = plistPaths.filter((plistPath) => !namesPlutilFailure(result.stderr, plistPath)); + if (rows.length !== readable.length) return new Map(); + const bundleIds = new Map(); + readable.forEach((plistPath, index) => { + const bundleId = rows[index]?.trim(); + if (bundleId) bundleIds.set(plistPath, bundleId); + }); + return bundleIds; +} + +function namesPlutilFailure(stderr: string, plistPath: string): boolean { + return stderr + .split('\n') + .some((line) => line.startsWith(plistPath) && line[plistPath.length] === ':'); +} + +function stripTrailingNewline(stdout: string): string { + return stdout.endsWith('\n') ? stdout.slice(0, -1) : stdout; +} + +/** + * The budget left for one probe read, or none when too little remains to ask a tool anything. Every + * read of this probe takes its share of `LAUNCH_SERVICES_PROBE_BUDGET_MS` and no more than its own + * cap, so one hung tool cannot consume the budget alone. + */ +function probeBudgetMs(deadline: Deadline, capMs: number): number | undefined { + const remainingMs = deadline.remainingMs(); + return remainingMs < MIN_PROBE_READ_MS ? undefined : Math.min(capMs, remainingMs); +} diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index aaf2069bda..33d494d7a1 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -300,6 +300,7 @@ agent-device snapshot -i --platform apple --target desktop - `--platform macos`, or `--platform apple --target desktop`, selects the host Mac as a `desktop` target. - Use an `app` session for normal app control: `open`, `snapshot`, `click`, `fill`, `press`, `scroll`, `back`, `screenshot`, `record`. In app sessions, `screenshot` captures the app window, not the whole desktop. - Use the other surfaces to inspect desktop-wide UI before choosing an app, mainly with `snapshot`, `get`, `is`, and `wait`: `frontmost-app` is the focused app, `desktop` is the visible windows across the desktop, and `menubar` is the active app's menu bar and system menu extras. To click or fill inside one app afterwards, open it in an `app` session. +- `open --platform macos` attaches to a copy of the app that is already running, including one you started yourself from a script or supervisor. If that copy has not finished registering with macOS, the open waits briefly for it before handing the URL over, so a copy that registers within that window keeps the arguments it was started with; a copy still unregistered after the wait gets the plain `open`, which can start a second instance. - `open --platform macos --surface menubar` targets one app's menu bar extras. Prefer it for status-item apps, which often expose little through the `app` surface. - Prefer selectors and `@ref`s; window positions shift between runs, so raw x/y is less stable. Use `click --button secondary` for context menus, then run `snapshot -i` again. - On `frontmost-app` and `menubar`, `press` and `click` post synthetic mouse events (`desktop` is inspect-only). `--hold-ms` keeps the button down (at least 40 ms, default 60 ms), `--count` sends independent clicks, and `--double-tap` sends double-click pairs. Apps like Finder read two clicks at the default interval as one double-click; pass an `--interval-ms` longer than the system double-click time to keep them apart. Long schedules get the time they need, and a cancelled or interrupted click releases the button. `--jitter-px` is ignored on these surfaces. From 047802293d179ec53bbb5871b695a4ef639a672c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 14:46:23 +0200 Subject: [PATCH 2/6] fix(macos): size the registration probe to finish the race it waits out The probe's 5 s ceiling exceeded the healthy open it precedes by an order of magnitude while the case it waits out registers within ~0.75 s. The ceiling now covers the full poll plus the reads ahead of it with measured room (reads cost well under 300 ms together on a healthy host), so a host too loaded to answer within the wait is dropped at the remaining-budget floor instead of holding the open for seconds the race never needs. The batched bundle-identifier read gains a hermetic invariant against the real plutil: one -extract answers for every plist it is named, in argument order, so one call can pair every candidate bundle; the unit fakes cannot carry that proof because they script the rows. --- .../launch-services-registration.test.ts | 84 +++++++++++++++++-- .../os/macos/launch-services-registration.ts | 15 ++-- 2 files changed, 83 insertions(+), 16 deletions(-) diff --git a/packages/platform-apple/src/os/macos/launch-services-registration.test.ts b/packages/platform-apple/src/os/macos/launch-services-registration.test.ts index a3ad150646..6960fab1b4 100644 --- a/packages/platform-apple/src/os/macos/launch-services-registration.test.ts +++ b/packages/platform-apple/src/os/macos/launch-services-registration.test.ts @@ -1,7 +1,10 @@ -import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; -import type { ExecResult } from '@agent-device/host-kit/command'; +import { runCmd, type ExecResult } from '@agent-device/host-kit/command'; import type { HostProcessInfo } from '@agent-device/host-kit/process'; +import { mkdtempForTest } from '../../__tests__/tmp-dir.ts'; vi.mock('node:timers/promises', () => ({ setTimeout: vi.fn(async () => undefined) })); vi.mock('@agent-device/host-kit/process', async (importOriginal) => ({ @@ -366,7 +369,7 @@ test('openBundle spends its budget on reads rather than on a wait with nothing l // A slow scan on a loaded host: by the time it answers, the probe's shared budget is nearly // gone. Spending the remainder on a 200 ms sleep and a read killed at 100 ms would spawn tools // that cannot answer, so the wait is left alone and the open runs. - vi.advanceTimersByTime(4_900); + vi.advanceTimersByTime(2_400); return { stdout: 'com.example.demo\ncom.ghost.app\n' }; } return {}; @@ -393,7 +396,7 @@ test('openBundle arms every probe read with only the budget the deadline still a // be armed with the remainder rather than with its own cap: a read armed above what is left could // run past the budget that bounds this probe. if (call.cmd === LAUNCH_SERVICES_TOOL) { - vi.advanceTimersByTime(3_600); + vi.advanceTimersByTime(1_900); return {}; } // Neither candidate is the opened bundle, so the wait never starts and the reads stay two. @@ -405,11 +408,11 @@ test('openBundle arms every probe read with only the budget the deadline still a expect(calls.map(({ cmd, timeoutMs }) => ({ cmd, timeoutMs }))).toEqual([ { cmd: LAUNCH_SERVICES_TOOL, timeoutMs: LAUNCH_SERVICES_READ_CAP_MS }, - { cmd: 'plutil', timeoutMs: 1_400 }, + { cmd: 'plutil', timeoutMs: 600 }, { cmd: 'open', timeoutMs: undefined }, ]); // The process-table read is the other seam this probe arms, and it is the one whose cap is largest. - expect(listHostProcesses).toHaveBeenCalledWith({ timeoutMs: 1_400 }); + expect(listHostProcesses).toHaveBeenCalledWith({ timeoutMs: 600 }); expect(mockSleep).not.toHaveBeenCalled(); }); @@ -421,7 +424,7 @@ test('openBundle skips a read whose share of the budget falls below what a tool // answer in time, and a zero timeout turns into no timer at all, which is how a probe that is // meant to be bounded would stop bounding itself. if (call.cmd === LAUNCH_SERVICES_TOOL) { - vi.advanceTimersByTime(4_950); + vi.advanceTimersByTime(2_450); return {}; } return call.cmd === 'plutil' ? { stdout: 'com.example.demo\ncom.ghost.app\n' } : {}; @@ -577,3 +580,70 @@ test('openBundle reports a probe that failed for a reason no typed code describe }), ); }); + +// `plutil -extract` answers one `-extract` for every plist it is given, in argument order, so one +// call can name every candidate bundle. This pins that batched shape against the real tool: a +// candidate set the probe cannot pair with the rows it gets back ends the whole fix, and scripted +// rows above cannot prove the tool actually behaves that way. +describe.skipIf(process.platform !== 'darwin')( + 'bundle identifier reads against the real plutil', + () => { + test('openBundle names every candidate plist in one real batch and waits on its own pid', async () => { + // Three bundles written to disk, the opened target second and under a name with a space. + // Their plists are real; only their being "running" is the mocked process table. + const root = await mkdtempForTest('ad-3406-plutil-batch-'); + const bundle = async (name: string, bundleId: string): Promise => { + const bundlePath = path.join(root, name); + await mkdir(path.join(bundlePath, 'Contents'), { recursive: true }); + await writeFile( + path.join(bundlePath, 'Contents', 'Info.plist'), + '\n' + + '\n' + + 'CFBundleIdentifier' + + `${bundleId}\n`, + ); + return bundlePath; + }; + const other = await bundle('Other.app', 'com.example.other'); + const target = await bundle('My Target.app', BUNDLE_ID); + const spaced = await bundle('Late Night.app', 'com.example.late-night'); + processTable.rows = [ + { pid: 4300, command: `${other}/Contents/MacOS/Other` }, + { pid: 4242, command: `${target}/Contents/MacOS/My Target -ProofFlag` }, + { pid: 4301, command: `${spaced}/Contents/MacOS/Late Night` }, + ] satisfies HostProcessInfo[]; + + let pidPinnedReads = 0; + // plutil runs for real through the exec seam. The LaunchServices view and `open` itself stay + // scripted: an unlisted-then-listed registration cannot be arranged with a real bundle that + // is not actually running, and a real `open` would launch a real copy. + const runCommand: AppleToolCommandExecutor = async (cmd, args, options) => { + if (cmd === 'open') return { exitCode: 0, stdout: '', stderr: '' }; + if (cmd === LAUNCH_SERVICES_TOOL) { + if (!args.some((arg) => arg.startsWith('pid='))) { + return { exitCode: 0, stdout: '', stderr: '' }; + } + pidPinnedReads += 1; + return { + exitCode: 0, + stdout: pidPinnedReads > 1 ? 'ASN:0x0-0x1234-"My Target":\n' : '', + stderr: '', + }; + } + return await runCmd(cmd, args, options); + }; + + try { + await openBundle(runCommand); + + // The copy is found only if the real batch paired its row with its path among three, so + // the wait ran and ended on the target's own pid rather than the open going out blind. + expect(pidPinnedReads).toBe(2); + expect(mockSleep).toHaveBeenCalledTimes(2); + } finally { + await rm(root, { recursive: true, force: true }); + } + }, 10_000); + }, +); diff --git a/packages/platform-apple/src/os/macos/launch-services-registration.ts b/packages/platform-apple/src/os/macos/launch-services-registration.ts index 9e9af2d56e..e97574d873 100644 --- a/packages/platform-apple/src/os/macos/launch-services-registration.ts +++ b/packages/platform-apple/src/os/macos/launch-services-registration.ts @@ -17,16 +17,13 @@ const LAUNCH_SERVICES_READ_TIMEOUT_MS = 1_000; const PROCESS_TABLE_TIMEOUT_MS = 2_000; const BUNDLE_ID_READ_TIMEOUT_MS = 1_500; /** - * Ceiling for the whole probe, shared by every read below. AGENTS.md asks an optional probe's - * budget to stay shorter than the operation it precedes, and this one exceeds a healthy `open -b` - * (~70 ms measured) on purpose — see the PR #3707 discussion: the operation this probe guards is - * the open REQUEST, which runs inside the command registry's 90 s envelope, and the probe's own - * worst case is bounded at this ceiling with every read failing open. The measured typical cost - * is what makes that trade proportionate: one `lsappinfo find` (12-40 ms) plus one process-table - * read (~0.2-0.3 s on a healthy host), and the 5 s ceiling is only ever reached when a host tool - * hangs — the open then proceeds late rather than being withheld. + * Ceiling for the whole probe, shared by every read below. It has to exceed the full wait plus + * the reads ahead of it — registration lands 0.5-0.75 s after a direct spawn — or the probe gives + * up on the only case it exists for; the reads themselves fit the remainder with room on a healthy + * host. AGENTS.md sizes a probe budget against the operation it precedes: the cost tradeoff of + * this one against `open -b` is measured and argued in PR #3707. */ -const LAUNCH_SERVICES_PROBE_BUDGET_MS = 5_000; +const LAUNCH_SERVICES_PROBE_BUDGET_MS = 2_500; /** * Below this remaining budget a read is worth skipping rather than spawning with a token amount. It * is also what keeps every read bounded: the exec seam turns a zero timeout into no timer at all, so From ae82b93c950b16510f6e2a78f504431f828d3f63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 15:24:57 +0200 Subject: [PATCH 3/6] test(macos): pin the real-plutil batch to the target's own pid The registration stub answered any pid-pinned read, so a batch that paired an identifier with the wrong bundle's process could still end the wait green. Only pid 4242's query now reports an ASN and the test asserts the exact read transcript, so a rotation of the row-to-path pairing (verified by a disposable mutation: 3 failed, 17 passed) fails this test on the target's pid rather than passing on another bundle's. --- .../launch-services-registration.test.ts | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/packages/platform-apple/src/os/macos/launch-services-registration.test.ts b/packages/platform-apple/src/os/macos/launch-services-registration.test.ts index 6960fab1b4..b6e8f829a2 100644 --- a/packages/platform-apple/src/os/macos/launch-services-registration.test.ts +++ b/packages/platform-apple/src/os/macos/launch-services-registration.test.ts @@ -106,9 +106,9 @@ const mockSleep = vi.mocked(sleep); const mockEmitDiagnostic = vi.mocked(emitDiagnostic); beforeEach(() => { + // Rows and call history reset together: a later test's `not.toHaveBeenCalled()` must not depend + // on run order. processTable.rows = []; - // The table seam's call history must not decide a later test's `not.toHaveBeenCalled()`: order - // safety comes from this reset, not from the luck of the run order. vi.mocked(listHostProcesses).mockClear(); }); @@ -614,20 +614,21 @@ describe.skipIf(process.platform !== 'darwin')( { pid: 4301, command: `${spaced}/Contents/MacOS/Late Night` }, ] satisfies HostProcessInfo[]; - let pidPinnedReads = 0; + const registrationReadsMade: string[][] = []; // plutil runs for real through the exec seam. The LaunchServices view and `open` itself stay // scripted: an unlisted-then-listed registration cannot be arranged with a real bundle that - // is not actually running, and a real `open` would launch a real copy. + // is not actually running, and a real `open` would launch a real copy. Only the target's own + // pid ever reports an ASN, so the wait ends only if pairing handed the probe that pid. const runCommand: AppleToolCommandExecutor = async (cmd, args, options) => { if (cmd === 'open') return { exitCode: 0, stdout: '', stderr: '' }; if (cmd === LAUNCH_SERVICES_TOOL) { - if (!args.some((arg) => arg.startsWith('pid='))) { - return { exitCode: 0, stdout: '', stderr: '' }; - } - pidPinnedReads += 1; + registrationReadsMade.push(args); + const pinned = args.at(-1) === 'pid=4242'; + const registered = + pinned && registrationReadsMade.filter((read) => read.at(-1) === 'pid=4242').length > 1; return { exitCode: 0, - stdout: pidPinnedReads > 1 ? 'ASN:0x0-0x1234-"My Target":\n' : '', + stdout: registered ? 'ASN:0x0-0x1234-"My Target":\n' : '', stderr: '', }; } @@ -637,9 +638,13 @@ describe.skipIf(process.platform !== 'darwin')( try { await openBundle(runCommand); - // The copy is found only if the real batch paired its row with its path among three, so - // the wait ran and ended on the target's own pid rather than the open going out blind. - expect(pidPinnedReads).toBe(2); + // The wait ended on pid 4242 — the target's process — which only happens if the real + // batch paired the target's identifier with the target's path among the three rows. + expect(registrationReadsMade).toEqual([ + ['find', `bundleid=${BUNDLE_ID}`], + ['find', `bundleid=${BUNDLE_ID}`, 'pid=4242'], + ['find', `bundleid=${BUNDLE_ID}`, 'pid=4242'], + ]); expect(mockSleep).toHaveBeenCalledTimes(2); } finally { await rm(root, { recursive: true, force: true }); From 587612880f5082abbabd5de0b23d1754d93df7a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 15:51:05 +0200 Subject: [PATCH 4/6] test(macos): point the probe routing comment at its owning test The final review caught that the comment in the routing test named host-provider-open.test.ts, which does not exist. The probe assertions live in launch-services-registration.test.ts, the file the comment should route a reader to. --- .../platform-apple/src/core/__tests__/tool-provider.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/platform-apple/src/core/__tests__/tool-provider.test.ts b/packages/platform-apple/src/core/__tests__/tool-provider.test.ts index fac7dcf5e8..9a672022af 100644 --- a/packages/platform-apple/src/core/__tests__/tool-provider.test.ts +++ b/packages/platform-apple/src/core/__tests__/tool-provider.test.ts @@ -178,7 +178,7 @@ test('local Apple tool provider exposes macOS host operations as semantic method const provider = createLocalAppleToolProvider({ runCommand: async (cmd, args, options) => { // The bundle open's LaunchServices registration probe is the seam's own read, asserted in - // host-provider-open.test.ts; this routing test only reports it as listing the bundle. + // launch-services-registration.test.ts; this routing test only reports it as listing the bundle. if (cmd === '/usr/bin/lsappinfo') { return { exitCode: 0, stdout: 'ASN:0x0-0x1234-"Demo":\n', stderr: '' }; } From 5a18f4bbfd3b94da6796976539d8a3547eb6acde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 18:13:12 +0200 Subject: [PATCH 5/6] fix(macos): read the process table through the provider's own executor The probe ran lsappinfo, plutil and the open through the injected Apple tool executor but read ps on the daemon's own host through the host-kit default. A scoped Apple provider could then pair pids found on one host with its lsappinfo and plutil on another. listHostProcesses({runCommand, timeoutMs}) routes the scan through the same seam and deadline the other reads already share, so the probe answers about one host end to end. The owning tests now script ps through fakeMacHost instead of mocking the process module. --- .../launch-services-registration.test.ts | 130 ++++++++++-------- .../os/macos/launch-services-registration.ts | 13 +- 2 files changed, 76 insertions(+), 67 deletions(-) diff --git a/packages/platform-apple/src/os/macos/launch-services-registration.test.ts b/packages/platform-apple/src/os/macos/launch-services-registration.test.ts index b6e8f829a2..5697c6e301 100644 --- a/packages/platform-apple/src/os/macos/launch-services-registration.test.ts +++ b/packages/platform-apple/src/os/macos/launch-services-registration.test.ts @@ -3,19 +3,13 @@ import path from 'node:path'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; import { runCmd, type ExecResult } from '@agent-device/host-kit/command'; -import type { HostProcessInfo } from '@agent-device/host-kit/process'; import { mkdtempForTest } from '../../__tests__/tmp-dir.ts'; vi.mock('node:timers/promises', () => ({ setTimeout: vi.fn(async () => undefined) })); -vi.mock('@agent-device/host-kit/process', async (importOriginal) => ({ - ...(await importOriginal()), - listHostProcesses: vi.fn(async () => processTable.rows), -})); vi.mock('@agent-device/host-kit/diagnostics', () => ({ emitDiagnostic: vi.fn() })); import { setTimeout as sleep } from 'node:timers/promises'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; -import { listHostProcesses } from '@agent-device/host-kit/process'; import { createLocalAppleMacOsHostProvider } from './host-provider.ts'; import type { AppleToolCommandExecutor } from '../../core/tool-provider-types.ts'; @@ -28,6 +22,9 @@ const GHOST_PLIST = `${GHOST_BUNDLE}/Contents/Info.plist`; const COLON_BUNDLE = '/Applications/My App: Pro.app'; const COLON_PLIST = `${COLON_BUNDLE}/Contents/Info.plist`; const LAUNCH_SERVICES_TOOL = '/usr/bin/lsappinfo'; +/** The probe reads the process table through the same executor seam, under this command. */ +const PROCESS_TABLE_TOOL = 'ps'; +const PROCESS_TABLE_ARGS = ['-ax', '-o', 'pid=,ppid=,command=']; const LAUNCH_SERVICES_READ_CAP_MS = 1_000; const BUNDLE_ID_CAP_MS = 1_500; const PROCESS_TABLE_CAP_MS = 2_000; @@ -35,17 +32,22 @@ const PROCESS_TABLE_CAP_MS = 2_000; /** `lsappinfo find` prints every instance it lists as an `ASN:` token, space-separated on one line. */ const REGISTERED = 'ASN:0x0-0x1234-"Demo": ASN:0x0-0x1244-"Demo":\n'; -const processTable = vi.hoisted(() => ({ rows: [] as unknown[] })); +/** The `ps` rows the fake host answers with, as the tool prints them: `pid ppid command`. */ +const processTable = { stdout: '' }; + +function psRows(rows: ReadonlyArray): string { + return rows.map(([pid, command]) => `${pid} 1 ${command}\n`).join(''); +} type RecordedCall = Readonly<{ cmd: string; args: string[]; timeoutMs: number | undefined }>; type Stub = Partial | Error; /** - * The host tools one bundle open reads — `lsappinfo`, `plutil`, and `open` itself — answered per - * call, with every call and the budget it was given recorded in order. An `Error` answer throws, the - * way a missing tool throws at the real seam. The process table is the separate `host-kit` seam this - * module reads through. + * The host tools one bundle open reads — `lsappinfo`, `ps`, `plutil`, and `open` itself — answered + * per call through the one executor seam the probe runs end to end, with every call and the budget + * it was given recorded in order. An `Error` answer throws, the way a missing tool throws at the + * real seam. The `ps` answer is the current process table. */ function fakeMacHost(respond: (call: RecordedCall, index: number) => Stub = () => ({})): { runCommand: AppleToolCommandExecutor; @@ -56,6 +58,9 @@ function fakeMacHost(respond: (call: RecordedCall, index: number) => Stub = () = const runCommand: AppleToolCommandExecutor = async (cmd, args, options) => { const call = { cmd, args, timeoutMs: options?.timeoutMs }; const index = calls.push(call) - 1; + if (cmd === PROCESS_TABLE_TOOL) { + return { stdout: processTable.stdout, stderr: '', exitCode: 0 }; + } const answer = respond(call, index); if (answer instanceof Error) throw answer; return { stdout: '', stderr: '', exitCode: 0, ...answer }; @@ -68,30 +73,35 @@ function fakeMacHost(respond: (call: RecordedCall, index: number) => Stub = () = }; } +/** The `ps` entry of a transcript, for asserting the budget the scan was armed with. */ +function processTableCall(calls: readonly RecordedCall[]): RecordedCall | undefined { + return calls.find((call) => call.cmd === PROCESS_TABLE_TOOL); +} + /** Two copies of the same bundle plus an unrelated app, none of them LaunchServices-listed. */ function runningCopies(): void { - processTable.rows = [ - { pid: 1, command: '/sbin/launchd' }, - { pid: 4242, command: `${DEMO_BUNDLE}/Contents/MacOS/Demo -DevFlag` }, - { pid: 4243, command: `${DEMO_BUNDLE}/Contents/MacOS/Demo` }, - { pid: 4300, command: `${GHOST_BUNDLE}/Contents/MacOS/Ghost` }, - ] satisfies HostProcessInfo[]; + processTable.stdout = psRows([ + [1, '/sbin/launchd'], + [4242, `${DEMO_BUNDLE}/Contents/MacOS/Demo -DevFlag`], + [4243, `${DEMO_BUNDLE}/Contents/MacOS/Demo`], + [4300, `${GHOST_BUNDLE}/Contents/MacOS/Ghost`], + ]); } /** The opened bundle's copy plus a copy of a bundle whose directory name contains `": "`. */ function runningCopiesIncludingColonBundle(): void { - processTable.rows = [ - { pid: 4242, command: `${DEMO_BUNDLE}/Contents/MacOS/Demo -DevFlag` }, - { pid: 4400, command: `${COLON_BUNDLE}/Contents/MacOS/My App: Pro` }, + processTable.stdout = psRows([ + [4242, `${DEMO_BUNDLE}/Contents/MacOS/Demo -DevFlag`], + [4400, `${COLON_BUNDLE}/Contents/MacOS/My App: Pro`], // A shell one-liner quoting some bundle's executable path looks exactly like a bundle process by // path shape, so it reaches the batch as a candidate whose plist does not exist. This is the // row that makes an unreadable-bundle name show up on stderr on a real host. - { pid: 4500, command: `/bin/zsh -c pkill -f ${DEMO_BUNDLE}/Contents/MacOS/Demo` }, - ] satisfies HostProcessInfo[]; + [4500, `/bin/zsh -c pkill -f ${DEMO_BUNDLE}/Contents/MacOS/Demo`], + ]); } function noRunningApps(): void { - processTable.rows = [{ pid: 1, command: '/sbin/launchd' }]; + processTable.stdout = psRows([[1, '/sbin/launchd']]); } function openBundle(runCommand: AppleToolCommandExecutor): Promise { @@ -106,10 +116,9 @@ const mockSleep = vi.mocked(sleep); const mockEmitDiagnostic = vi.mocked(emitDiagnostic); beforeEach(() => { - // Rows and call history reset together: a later test's `not.toHaveBeenCalled()` must not depend - // on run order. - processTable.rows = []; - vi.mocked(listHostProcesses).mockClear(); + // The table resets with every test so a later test's `not.toHaveBeenCalled`-style expectation on + // the scan does not depend on run order. + processTable.stdout = ''; }); afterEach(() => { @@ -130,7 +139,6 @@ test('openBundle runs open at once when LaunchServices already lists an instance { cmd: LAUNCH_SERVICES_TOOL, args: ['find', `bundleid=${BUNDLE_ID}`] }, { cmd: 'open', args: ['-b', BUNDLE_ID] }, ]); - expect(listHostProcesses).not.toHaveBeenCalled(); expect(mockSleep).not.toHaveBeenCalled(); // One read of the registration view, inside its own cap. expect(registrationReads(calls)[0]?.timeoutMs).toBeLessThanOrEqual(LAUNCH_SERVICES_READ_CAP_MS); @@ -138,17 +146,19 @@ test('openBundle runs open at once when LaunchServices already lists an instance test('openBundle runs open at once when no process of the opened bundle runs', async () => { noRunningApps(); - const { runCommand, transcript } = fakeMacHost(); + const { runCommand, calls, transcript } = fakeMacHost(); await openBundle(runCommand); // The first-launch case: nothing is listed, the process table holds no bundle whose identifier // matches, so this open is the launch and no plist of a candidate is read to prove a copy exists. + // The scan itself runs through the same executor seam as every other probe read. expect(transcript()).toEqual([ { cmd: LAUNCH_SERVICES_TOOL, args: ['find', `bundleid=${BUNDLE_ID}`] }, + { cmd: PROCESS_TABLE_TOOL, args: PROCESS_TABLE_ARGS }, { cmd: 'open', args: ['-b', BUNDLE_ID] }, ]); - expect(listHostProcesses).toHaveBeenCalledWith({ timeoutMs: PROCESS_TABLE_CAP_MS }); + expect(processTableCall(calls)?.timeoutMs).toBe(PROCESS_TABLE_CAP_MS); expect(mockSleep).not.toHaveBeenCalled(); }); @@ -180,6 +190,7 @@ test('openBundle waits for LaunchServices to list a copy that runs unlisted', as // so the wait ends on whichever one the registration names — and never on the unrelated app. expect(transcript().slice(0, -1)).toEqual([ { cmd: LAUNCH_SERVICES_TOOL, args: ['find', `bundleid=${BUNDLE_ID}`] }, + { cmd: PROCESS_TABLE_TOOL, args: PROCESS_TABLE_ARGS }, { cmd: 'plutil', args: ['-extract', 'CFBundleIdentifier', 'raw', DEMO_PLIST, GHOST_PLIST] }, { cmd: LAUNCH_SERVICES_TOOL, args: ['find', `bundleid=${BUNDLE_ID}`, 'pid=4242'] }, { cmd: LAUNCH_SERVICES_TOOL, args: ['find', `bundleid=${BUNDLE_ID}`, 'pid=4243'] }, @@ -213,6 +224,7 @@ test('openBundle never waits for a running copy of some other bundle', async () expect(transcript()).toEqual([ { cmd: LAUNCH_SERVICES_TOOL, args: ['find', `bundleid=${BUNDLE_ID}`] }, + { cmd: PROCESS_TABLE_TOOL, args: PROCESS_TABLE_ARGS }, { cmd: 'plutil', args: ['-extract', 'CFBundleIdentifier', 'raw', DEMO_PLIST, GHOST_PLIST] }, { cmd: 'open', args: ['-b', BUNDLE_ID] }, ]); @@ -333,12 +345,11 @@ test('openBundle treats a failing first registration read as an instance to atta { cmd: LAUNCH_SERVICES_TOOL, args: ['find', `bundleid=${BUNDLE_ID}`] }, { cmd: 'open', args: ['-b', BUNDLE_ID] }, ]); - expect(listHostProcesses).not.toHaveBeenCalled(); expect(mockSleep).not.toHaveBeenCalled(); }); test('openBundle reports a process table that answers nothing rather than reading it as empty', async () => { - processTable.rows = []; + processTable.stdout = ''; const { runCommand, transcript } = fakeMacHost((call) => call.cmd === 'plutil' ? { stdout: 'com.example.demo\n' } : {}, ); @@ -349,6 +360,7 @@ test('openBundle reports a process table that answers nothing rather than readin // running" from it would skip the wait and reproduce the duplicate this seam exists to prevent. expect(transcript()).toEqual([ { cmd: LAUNCH_SERVICES_TOOL, args: ['find', `bundleid=${BUNDLE_ID}`] }, + { cmd: PROCESS_TABLE_TOOL, args: PROCESS_TABLE_ARGS }, { cmd: 'open', args: ['-b', BUNDLE_ID] }, ]); expect(mockEmitDiagnostic).toHaveBeenCalledWith( @@ -380,6 +392,7 @@ test('openBundle spends its budget on reads rather than on a wait with nothing l expect(transcript()).toEqual([ { cmd: LAUNCH_SERVICES_TOOL, args: ['find', `bundleid=${BUNDLE_ID}`] }, + { cmd: PROCESS_TABLE_TOOL, args: PROCESS_TABLE_ARGS }, { cmd: 'plutil', args: ['-extract', 'CFBundleIdentifier', 'raw', DEMO_PLIST, GHOST_PLIST] }, { cmd: 'open', args: ['-b', BUNDLE_ID] }, ]); @@ -408,11 +421,12 @@ test('openBundle arms every probe read with only the budget the deadline still a expect(calls.map(({ cmd, timeoutMs }) => ({ cmd, timeoutMs }))).toEqual([ { cmd: LAUNCH_SERVICES_TOOL, timeoutMs: LAUNCH_SERVICES_READ_CAP_MS }, + // The process-table read is armed through this same seam now, and its cap is the largest; with + // 600 ms left it takes the remainder, the way the identifier read beside it does. + { cmd: PROCESS_TABLE_TOOL, timeoutMs: 600 }, { cmd: 'plutil', timeoutMs: 600 }, { cmd: 'open', timeoutMs: undefined }, ]); - // The process-table read is the other seam this probe arms, and it is the one whose cap is largest. - expect(listHostProcesses).toHaveBeenCalledWith({ timeoutMs: 600 }); expect(mockSleep).not.toHaveBeenCalled(); }); @@ -437,26 +451,20 @@ test('openBundle skips a read whose share of the budget falls below what a tool { cmd: LAUNCH_SERVICES_TOOL, args: ['find', `bundleid=${BUNDLE_ID}`] }, { cmd: 'open', args: ['-b', BUNDLE_ID] }, ]); - expect(listHostProcesses).not.toHaveBeenCalled(); expect(mockSleep).not.toHaveBeenCalled(); expect(mockEmitDiagnostic).not.toHaveBeenCalled(); }); test('openBundle reads identifiers only for candidates that are app bundles', async () => { - processTable.rows = [ - ...([ - { pid: 4242, command: `${DEMO_BUNDLE}/Contents/MacOS/Demo -DevFlag` }, - { pid: 4300, command: `${GHOST_BUNDLE}/Contents/MacOS/Ghost` }, - // An app extension and a driver extension are bundles of their own, and most rows carrying - // `/Contents/MacOS/` on a real host are these and XPC services, whose identifiers match no app - // anyone opens. Keeping them out is what keeps the batch the size of the running apps. - { - pid: 4600, - command: `${DEMO_BUNDLE}/Contents/PlugIns/Widget.appex/Contents/MacOS/Widget`, - }, - { pid: 4700, command: '/opt/dext/driver.dext/Contents/MacOS/driver' }, - ] satisfies HostProcessInfo[]), - ]; + processTable.stdout = psRows([ + [4242, `${DEMO_BUNDLE}/Contents/MacOS/Demo -DevFlag`], + [4300, `${GHOST_BUNDLE}/Contents/MacOS/Ghost`], + // An app extension and a driver extension are bundles of their own, and most rows carrying + // `/Contents/MacOS/` on a real host are these and XPC services, whose identifiers match no app + // anyone opens. Keeping them out is what keeps the batch the size of the running apps. + [4600, `${DEMO_BUNDLE}/Contents/PlugIns/Widget.appex/Contents/MacOS/Widget`], + [4700, '/opt/dext/driver.dext/Contents/MacOS/driver'], + ]); const { runCommand, transcript } = fakeMacHost((call) => call.cmd === 'plutil' ? { stdout: '\n\n' } : {}, ); @@ -465,6 +473,7 @@ test('openBundle reads identifiers only for candidates that are app bundles', as expect(transcript()).toEqual([ { cmd: LAUNCH_SERVICES_TOOL, args: ['find', `bundleid=${BUNDLE_ID}`] }, + { cmd: PROCESS_TABLE_TOOL, args: PROCESS_TABLE_ARGS }, { cmd: 'plutil', args: ['-extract', 'CFBundleIdentifier', 'raw', DEMO_PLIST, GHOST_PLIST], @@ -510,7 +519,6 @@ test('openBundle leaves the open alone when the first registration read is kille { cmd: LAUNCH_SERVICES_TOOL, args: ['find', `bundleid=${BUNDLE_ID}`] }, { cmd: 'open', args: ['-b', BUNDLE_ID] }, ]); - expect(listHostProcesses).not.toHaveBeenCalled(); expect(mockSleep).not.toHaveBeenCalled(); expect(mockEmitDiagnostic).not.toHaveBeenCalled(); }); @@ -608,19 +616,23 @@ describe.skipIf(process.platform !== 'darwin')( const other = await bundle('Other.app', 'com.example.other'); const target = await bundle('My Target.app', BUNDLE_ID); const spaced = await bundle('Late Night.app', 'com.example.late-night'); - processTable.rows = [ - { pid: 4300, command: `${other}/Contents/MacOS/Other` }, - { pid: 4242, command: `${target}/Contents/MacOS/My Target -ProofFlag` }, - { pid: 4301, command: `${spaced}/Contents/MacOS/Late Night` }, - ] satisfies HostProcessInfo[]; + processTable.stdout = psRows([ + [4300, `${other}/Contents/MacOS/Other`], + [4242, `${target}/Contents/MacOS/My Target -ProofFlag`], + [4301, `${spaced}/Contents/MacOS/Late Night`], + ]); const registrationReadsMade: string[][] = []; - // plutil runs for real through the exec seam. The LaunchServices view and `open` itself stay - // scripted: an unlisted-then-listed registration cannot be arranged with a real bundle that - // is not actually running, and a real `open` would launch a real copy. Only the target's own - // pid ever reports an ASN, so the wait ends only if pairing handed the probe that pid. + // plutil runs for real through the exec seam. The LaunchServices view, the process table and + // `open` itself stay scripted: an unlisted-then-listed registration cannot be arranged with a + // real bundle that is not actually running, and a real `open` would launch a real copy. Only + // the target's own pid ever reports an ASN, so the wait ends only if pairing handed the probe + // that pid. const runCommand: AppleToolCommandExecutor = async (cmd, args, options) => { if (cmd === 'open') return { exitCode: 0, stdout: '', stderr: '' }; + if (cmd === PROCESS_TABLE_TOOL) { + return { exitCode: 0, stdout: processTable.stdout, stderr: '' }; + } if (cmd === LAUNCH_SERVICES_TOOL) { registrationReadsMade.push(args); const pinned = args.at(-1) === 'pid=4242'; diff --git a/packages/platform-apple/src/os/macos/launch-services-registration.ts b/packages/platform-apple/src/os/macos/launch-services-registration.ts index e97574d873..f2a581a601 100644 --- a/packages/platform-apple/src/os/macos/launch-services-registration.ts +++ b/packages/platform-apple/src/os/macos/launch-services-registration.ts @@ -1,11 +1,7 @@ import path from 'node:path'; import { setTimeout as sleep } from 'node:timers/promises'; import { AppError } from '@agent-device/kernel/errors'; -import { - isCommandTimeoutError, - withoutCommandExecutorOverride, - type ExecResult, -} from '@agent-device/host-kit/command'; +import { isCommandTimeoutError, type ExecResult } from '@agent-device/host-kit/command'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { listHostProcesses } from '@agent-device/host-kit/process'; import { Deadline } from '@agent-device/host-kit/retry'; @@ -147,9 +143,10 @@ async function findUnlistedInstancePids( ): Promise { const timeoutMs = probeBudgetMs(deadline, PROCESS_TABLE_TIMEOUT_MS); if (timeoutMs === undefined) return []; - const processes = await withoutCommandExecutorOverride( - async () => await listHostProcesses({ timeoutMs }), - ); + // The provider's own executor, so every read of this probe answers about the same host its + // `lsappinfo`, `plutil` and `open` run on: a scoped Apple provider must never pair pids from one + // host with tools on another. + const processes = await listHostProcesses({ runCommand, timeoutMs }); if (processes.length === 0) { // A macOS host always has a process table, so no rows at all is the read failing rather than a // host running nothing. The open proceeds without this probe knowing anything, so the reason is From ee2a12ef551548e133bb78c5ef72104240c4db3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 18:23:47 +0200 Subject: [PATCH 6/6] test(macos): let the fake host answer the process-table read like any read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fakeMacHost recorded the ps call and returned the table before consulting the responder, so the scan could not be made to throw, fail, or time out even though the probe now runs it through the same executor seam the Error-answering stub exists for. The responder is consulted first and the table is only the default answer. Three owning scenarios pin what the probe does when the scan itself fails: a tool that cannot spawn and a read killed at its budget end the probe with a typed diagnostic and the original open, and a non-zero ps exit is the unreadable table the empty answer already was — never 'nothing runs'. --- .../launch-services-registration.test.ts | 90 +++++++++++++++++-- 1 file changed, 85 insertions(+), 5 deletions(-) diff --git a/packages/platform-apple/src/os/macos/launch-services-registration.test.ts b/packages/platform-apple/src/os/macos/launch-services-registration.test.ts index 5697c6e301..80fdee1dfb 100644 --- a/packages/platform-apple/src/os/macos/launch-services-registration.test.ts +++ b/packages/platform-apple/src/os/macos/launch-services-registration.test.ts @@ -47,7 +47,8 @@ type Stub = Partial | Error; * The host tools one bundle open reads — `lsappinfo`, `ps`, `plutil`, and `open` itself — answered * per call through the one executor seam the probe runs end to end, with every call and the budget * it was given recorded in order. An `Error` answer throws, the way a missing tool throws at the - * real seam. The `ps` answer is the current process table. + * real seam. `ps` answers with the current process table unless the responder overrides it, so a + * test can make the scan throw, fail, or time out like any other read. */ function fakeMacHost(respond: (call: RecordedCall, index: number) => Stub = () => ({})): { runCommand: AppleToolCommandExecutor; @@ -58,12 +59,10 @@ function fakeMacHost(respond: (call: RecordedCall, index: number) => Stub = () = const runCommand: AppleToolCommandExecutor = async (cmd, args, options) => { const call = { cmd, args, timeoutMs: options?.timeoutMs }; const index = calls.push(call) - 1; - if (cmd === PROCESS_TABLE_TOOL) { - return { stdout: processTable.stdout, stderr: '', exitCode: 0 }; - } const answer = respond(call, index); if (answer instanceof Error) throw answer; - return { stdout: '', stderr: '', exitCode: 0, ...answer }; + const fallback = cmd === PROCESS_TABLE_TOOL ? { stdout: processTable.stdout } : {}; + return { stdout: '', stderr: '', exitCode: 0, ...fallback, ...answer }; }; return { runCommand, @@ -348,6 +347,87 @@ test('openBundle treats a failing first registration read as an instance to atta expect(mockSleep).not.toHaveBeenCalled(); }); +test('openBundle opens unchanged when the process-table read cannot run', async () => { + runningCopies(); + const { runCommand, calls, transcript } = fakeMacHost((call) => + // The scan now runs through the provider's executor, so like every other probe read it can + // fail outright. A ps that cannot spawn must end the probe, not be read as an empty table. + call.cmd === PROCESS_TABLE_TOOL + ? new AppError('TOOL_MISSING', 'Required macOS tool not found: ps') + : {}, + ); + + await openBundle(runCommand); + + expect(transcript()).toEqual([ + { cmd: LAUNCH_SERVICES_TOOL, args: ['find', `bundleid=${BUNDLE_ID}`] }, + { cmd: PROCESS_TABLE_TOOL, args: PROCESS_TABLE_ARGS }, + { cmd: 'open', args: ['-b', BUNDLE_ID] }, + ]); + expect(calls.at(-1)).toEqual({ cmd: 'open', args: ['-b', BUNDLE_ID] }); + expect(mockEmitDiagnostic).toHaveBeenCalledWith( + expect.objectContaining({ + level: 'warn', + phase: 'macos_launch_services_registration_probe_failed', + data: expect.objectContaining({ bundleId: BUNDLE_ID, reason: 'TOOL_MISSING' }), + }), + ); + expect(mockSleep).not.toHaveBeenCalled(); +}); + +test('openBundle leaves the wait alone when the process-table read is killed at its budget', async () => { + runningCopies(); + const { runCommand, transcript } = fakeMacHost((call) => + // The exec seam rejects a command it killed at its own timeoutMs. The probe shares one budget + // across reads, so a scan killed mid-probe says nothing about what runs: no candidates, no + // wait, and the open proceeds exactly as it did before the probe existed. + call.cmd === PROCESS_TABLE_TOOL + ? new AppError('COMMAND_FAILED', 'ps timed out after 2000ms', { timeoutMs: 2_000 }) + : {}, + ); + + await openBundle(runCommand); + + expect(transcript()).toEqual([ + { cmd: LAUNCH_SERVICES_TOOL, args: ['find', `bundleid=${BUNDLE_ID}`] }, + { cmd: PROCESS_TABLE_TOOL, args: PROCESS_TABLE_ARGS }, + { cmd: 'open', args: ['-b', BUNDLE_ID] }, + ]); + expect(mockEmitDiagnostic).toHaveBeenCalledWith( + expect.objectContaining({ + level: 'warn', + phase: 'macos_launch_services_registration_probe_failed', + data: expect.objectContaining({ bundleId: BUNDLE_ID, reason: 'COMMAND_FAILED' }), + }), + ); + expect(mockSleep).not.toHaveBeenCalled(); +}); + +test('openBundle treats a failed process-table exit as an unreadable table, not as no processes', async () => { + runningCopies(); + const { runCommand, transcript } = fakeMacHost((call) => + call.cmd === PROCESS_TABLE_TOOL ? { exitCode: 1, stderr: 'ps: operation not permitted' } : {}, + ); + + await openBundle(runCommand); + + // `listHostProcesses` answers a non-zero exit with no rows, which the probe reads the same way as + // an empty answer: the read failed, and inferring "nothing runs" from it would skip the wait. + expect(transcript()).toEqual([ + { cmd: LAUNCH_SERVICES_TOOL, args: ['find', `bundleid=${BUNDLE_ID}`] }, + { cmd: PROCESS_TABLE_TOOL, args: PROCESS_TABLE_ARGS }, + { cmd: 'open', args: ['-b', BUNDLE_ID] }, + ]); + expect(mockEmitDiagnostic).toHaveBeenCalledWith( + expect.objectContaining({ + level: 'warn', + phase: 'macos_launch_services_process_table_unreadable', + data: { bundleId: BUNDLE_ID }, + }), + ); + expect(mockSleep).not.toHaveBeenCalled(); +}); + test('openBundle reports a process table that answers nothing rather than reading it as empty', async () => { processTable.stdout = ''; const { runCommand, transcript } = fakeMacHost((call) =>