Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## Unreleased

- Fix host-routed screen observations with Accessibility elements by validating their semantic owner separately from the screen raster target. #715, #710.
- Fix application name and bundle resolution being blocked by reaped processes lingering in LaunchServices; require repeated native absence while retaining refusal for uncertain or changing process identities. #709.

## 4.3.4 - 2026-09-11

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ extension ApplicationService {
guard let generation = observation.identity,
generation > 0
else {
// LaunchServices can retain a reaped PID. Only two explicit native absence reads
// exclude it; a new generation or any uncertain result still makes inventory partial.
if observation == .absent, identityProvider(processIdentifier) == .absent {
continue
}
if observation == .permissionDenied,
Self.confirmsNonTargetableMutationProcess(
processIdentifier,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ public enum SystemIdentityResolver {
enum ProcessStartIdentityObservation: Equatable, Sendable {
case identity(UInt64)
case permissionDenied
case absent
case unavailable

var identity: UInt64? {
Expand Down Expand Up @@ -125,8 +126,12 @@ public enum SystemIdentityResolver {
errorCode: Int32) -> ProcessStartIdentityObservation
{
guard bytesRead == Int32(MemoryLayout<proc_bsdinfo>.stride) else {
// Only a failed full read with explicit EPERM proves the verified permission-denied path.
return bytesRead <= 0 && errorCode == EPERM ? .permissionDenied : .unavailable
guard bytesRead <= 0 else { return .unavailable }
switch errorCode {
case EPERM: return .permissionDenied
case ESRCH: return .absent
default: return .unavailable
}
}
let seconds = UInt64(info.pbi_start_tvsec)
let microseconds = UInt64(info.pbi_start_tvusec)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -832,6 +832,79 @@ extension ApplicationInventoryTimeoutTests {
}
}

@Test
@MainActor
func `reaped native process in LaunchServices inventory does not block a live named target`() async throws {
let child = Process()
child.executableURL = URL(fileURLWithPath: "/usr/bin/true")
try child.run()
child.waitUntilExit()
let deadPID = child.processIdentifier
let existenceResult = kill(deadPID, 0)
let existenceError = errno
#expect(existenceResult == -1 && existenceError == ESRCH)

let target = Self.mutationApplication(pid: getpid(), name: "Editor", policy: .regular)
let stale = Self.mutationApplication(pid: deadPID, name: "Stale helper")
let nativeReads = AutomationTestLockedValue(0)
let service = Self.mutationService(
applications: [target, stale],
identityProvider: { pid in
guard pid == deadPID else { return .identity(90) }
nativeReads.withValue { $0 += 1 }
return SystemIdentityResolver.processStartIdentityObservation(pid)
},
eligibilityProvider: { _ in
Issue.record("A missing native process must not use permission-denied eligibility")
return nil
})

let inventory = try await service.applicationMutationInventory()
#expect(inventory.isComplete)
#expect(inventory.warnings.isEmpty)
#expect(inventory.items.map(\.processIdentifier) == [target.processIdentifier])
#expect(nativeReads.value == 2)

let planner = DesktopTargetPlanning.ApplicationMutationPlanner(
inventoryProvider: { try await service.applicationMutationInventory() })
for identifier in try [target.name, #require(target.bundleIdentifier)] {
let plan = try await planner.plan(identifier: identifier)
#expect(plan.processIdentity == target.processIdentity)
}
}

@Test
@MainActor
func `changing or uncertain native absence leaves mutation inventory partial`() async throws {
let transitions: [[SystemIdentityResolver.ProcessStartIdentityObservation]] = [
[.absent, .identity(91)],
[.absent, .permissionDenied],
[.absent, .unavailable],
[.identity(90), .absent],
[.unavailable, .absent],
]
let target = Self.mutationApplication(pid: 41201, name: "Editor", policy: .regular)
let helper = Self.mutationApplication(pid: 41202, name: "Helper")
for observations in transitions {
let reads = AutomationTestLockedValue(0)
let service = Self.mutationService(
applications: [target, helper],
identityProvider: { pid in
guard pid == helper.processIdentifier else { return .identity(90) }
return reads.withValue { index in
defer { index += 1 }
return observations[min(index, observations.count - 1)]
}
},
eligibilityProvider: { _ in nil })
let inventory = try await service.applicationMutationInventory()
#expect(!inventory.isComplete)
#expect(inventory.items.map(\.processIdentifier) == [target.processIdentifier])
#expect(inventory.warnings.count == 1)
#expect(reads.value <= 2)
}
}

@Test
@MainActor
func `legacy optional identity injection never probes eligibility or upgrades nil to denial`() async throws {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,30 @@ struct SystemIdentityResolverTests {
info: info, bytesRead: size, errorCode: EPERM)
#expect(readable == .identity(123_000_456))
#expect(readable.identity == 123_000_456)
#expect(SystemIdentityResolver.processStartIdentityObservation(
info: info, bytesRead: size, errorCode: ESRCH) == readable)

for bytesRead in [Int32(0), -1] {
let denied = SystemIdentityResolver.processStartIdentityObservation(
info: info, bytesRead: bytesRead, errorCode: EPERM)
#expect(denied == .permissionDenied)
#expect(denied.identity == nil)
for errorCode in [0, ESRCH, EIO, EACCES] {
let absent = SystemIdentityResolver.processStartIdentityObservation(
info: info, bytesRead: bytesRead, errorCode: ESRCH)
#expect(absent == .absent)
#expect(absent.identity == nil)
for errorCode in [0, EIO, EACCES] {
let unavailable = SystemIdentityResolver.processStartIdentityObservation(
info: info, bytesRead: bytesRead, errorCode: errorCode)
#expect(unavailable == .unavailable)
#expect(unavailable.identity == nil)
}
}
for bytesRead in [size - 1, size + 1] {
#expect(SystemIdentityResolver.processStartIdentityObservation(
info: info, bytesRead: bytesRead, errorCode: EPERM) == .unavailable)
for errorCode in [EPERM, ESRCH] {
#expect(SystemIdentityResolver.processStartIdentityObservation(
info: info, bytesRead: bytesRead, errorCode: errorCode) == .unavailable)
}
}
info.pbi_start_tvsec = UInt64.max
info.pbi_start_tvusec = 999_999
Expand Down
6 changes: 6 additions & 0 deletions docs/application-resolving.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,12 @@ App mutations require an exact name, exact bundle ID, or explicit PID. Name and

The native mutation inventory excludes an unreadable helper without marking the inventory partial only when repeated observations agree: activation policy is explicitly prohibited, an exact-sized short-BSD response identifies the expected PID with an effective UID different from the runtime host, and full process-generation reads actually fail with `EPERM`. UID alone never predicts denial, and short BSD never supplies a generation receipt. Unknown policy, other failures, changing evidence, and a readable generation that disappears or changes all remain fail-closed. These checks share the inventory's one-second off-MainActor budget and retained native worker; they do not change read-only discovery or generation-pinned explicit-PID lifecycle targeting.

LaunchServices can also retain records for processes that have exited. Two consecutive failed full-BSD generation
reads with explicit `ESRCH` confirm native absence and exclude that stale row without making mutation inventory
partial. A partial native read, arbitrary failure, or transition between absence and a readable generation remains
an uncertain omission. A process that disappears after its generation was read also remains an omission; retry with
a fresh inventory. Read-only listing and explicit-PID targeting keep their existing behavior.

### Allowed Redundancy

These legacy management forms are valid and equivalent:
Expand Down
3 changes: 2 additions & 1 deletion docs/commands/app.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ read_when:

## Implementation notes
- App mutations accept only an exact case-insensitive application name, exact bundle ID, or explicit `PID:<n>`/`--pid`. Partial-name matching remains available to read-only discovery, but a mutation such as `--app Saf` is refused before any lifecycle action is dispatched.
- Prohibited helpers remain exact-name/bundle candidates whenever their process generation is readable. Only repeatedly confirmed foreign-effective-UID, prohibited rows whose full generation reads fail with `EPERM` are excluded from native mutation inventory without an omission warning. All uncertain omissions still prevent name/bundle uniqueness; read-only listing and explicit-PID lifecycle targeting are unchanged. See [mutation inventory completeness](../application-resolving.md#mutation-inventory-completeness).
- Prohibited helpers remain exact-name/bundle candidates whenever their process generation is readable. Unreadable live helpers are excluded without an omission warning only when repeated observations confirm a foreign effective UID, prohibited activation policy, and full generation reads failing with `EPERM`. All uncertain omissions still prevent name/bundle uniqueness; read-only listing and explicit-PID lifecycle targeting are unchanged. See [mutation inventory completeness](../application-resolving.md#mutation-inventory-completeness).
- Stale LaunchServices rows for exited processes are excluded only after two native generation reads return `ESRCH`. A changing generation, partial read, or other error still prevents name/bundle uniqueness.
- Launch resolves explicit paths, bundle IDs, PID selectors, and friendly names on the selected runtime host. Without `--foreground`, it may only return an exact already-running app as a verified no-op; it may resolve the application URL but never dispatches a LaunchServices open/start. Cold launch, `--open`, `--new-instance`, and relaunch refuse before dispatch because macOS does not provide a trustworthy nonactivation guarantee. These refusals report `INTERACTION_FAILED`, `effect: refused`, `retry_safe: true`, and `mutation_dispatched: false`, with explicit foreground guidance. Background launch also requires a host that advertises this exact no-op contract, so a rolling upgrade cannot delegate to an older host that would cold-launch. The deprecated `--no-focus` flag remains a no-op compatibility alias.
- Background no-op `launch --wait-ready` and `--wait-for-window` retain the selected PID/process-generation receipt throughout their read-only waits. A readiness failure remains explicitly retry-safe with `mutation_dispatched: false`. A `PID:` selector stays pinned to that exact process generation for both the no-op and a plain foreground activation; it cannot be combined with `--open` or `--new-instance`. Foreground launch keeps the full existing LaunchServices behavior for path/name/bundle selectors: it can start windowless/accessory apps, deliver documents/URLs, create a distinct process, and wait up to 10 seconds for a real WindowServer window. `relaunch` retains its single `--wait-until-ready` spelling and requires `--foreground` before the target is resolved or quit.
- JSON launch output returns the launch-bound numeric compatibility field `process_start_identity` plus the lossless authoritative string `process_start_identity_decimal` beside `pid`, along with refreshed `window_count`, `window_ready`, and `window_ids`. Relaunch uses `new_process_start_identity` and authoritative `new_process_start_identity_decimal`. JSON-number consumers must not use the numeric forms for exact comparison because values above 2^53 can lose precision. A current native host captures that process generation from the exact process selected by LaunchServices and refuses the result if the PID is recycled before return. Older runtime hosts may omit the process identity for foreground launch, but background launch fails closed unless the host advertises the safe no-op contract; cleanup callers must never probe a returned PID to manufacture a new receipt. `window_identity` is `exact` when the window IDs came from WindowServer and `unknown` for an older runtime host that cannot provide that metadata.
Expand Down