Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
4e13a8d
feat(remote): add a host-allocated macos-app lease backend
janicduplessis Oct 5, 2026
5c958e1
fix(remote): refuse device selectors and require platform macos under…
janicduplessis Oct 5, 2026
2fee8dd
fix(daemon): confine lease-exempt commands under a macos-app lease
janicduplessis Oct 5, 2026
af8cb74
fix(daemon): require the leased app session under a macos-app lease
janicduplessis Oct 5, 2026
b8f5167
refactor(daemon): share the host admin JSON body reader and sender
janicduplessis Oct 5, 2026
f0ddf2c
refactor(daemon): take the macos-app claim exemption from the device …
janicduplessis Oct 5, 2026
b2993af
refactor(remote): share the remote temp artifact path shape with the …
janicduplessis Oct 5, 2026
0c467d7
refactor: derive the lease backend enums from LEASE_BACKENDS
janicduplessis Oct 5, 2026
67e575e
fix(daemon): cap tenant heartbeats of a host-allocated lease at the h…
janicduplessis Oct 5, 2026
474b464
fix(daemon): advertise the macos-app lease backend only on a macOS host
janicduplessis Oct 5, 2026
edf873e
fix(macos-helper): act only inside the session app's windows on the n…
janicduplessis Oct 5, 2026
332c35f
Merge remote-tracking branch 'origin/main' into feat/macos-app-lease-…
janicduplessis Oct 5, 2026
7dfeb33
refactor(remote): keep the remote temp artifact path shape inside dae…
janicduplessis Oct 5, 2026
937471b
fix(daemon): omit host paths and the host device from macos-app lease…
janicduplessis Oct 5, 2026
1b0d9a0
fix(remote): bind the host-allocated macos-app lease on connect
janicduplessis Oct 6, 2026
4c09309
fix(remote): never release a host-allocated macos-app lease from a te…
janicduplessis Oct 6, 2026
d19ce0f
docs(remote): say a tenant cannot release a host-allocated macos-app …
janicduplessis Oct 6, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,13 @@ func pressInBackground(
)
}
let point = CGPoint(x: request.x, y: request.y)
try requireAppWindowPoint(point, app: app)
cursor?.move(to: point)
guard let target = resolvePressTarget(app: app, point: point),
let mechanism = perform(target)
else {
let target = resolvePressTarget(app: app, point: point)
if let target, isInMenuBar(target.element) {
throw refusal(.noAccessibleTarget, "the point is on the app's menu bar", app: app)
}
guard let target, let mechanism = perform(target) else {
throw refusal(.noAccessibleTarget, "no pressable accessibility element at the point", app: app)
}
var clicks = 1
Expand Down Expand Up @@ -165,12 +168,16 @@ func fillInBackground(
app: NSRunningApplication,
cursor: GhostCursor?
) throws -> BackgroundTextResponse {
try requireAppWindowPoint(point, app: app)
cursor?.move(to: point)
let hit = elementAtPoint(in: app, point: point)
let chain = hit.map(pressSearchChain) ?? []
let fallback = actionWindow(app: app, hit: hit).flatMap { window in
smallestElement(in: window, containing: point, where: isTextInput)
}
if let input = chain.first(where: isTextInput) ?? fallback, isInMenuBar(input) {
throw refusal(.noAccessibleTarget, "the point is on the app's menu bar", app: app)
}
guard let input = chain.first(where: isTextInput) ?? fallback,
isAttributeSettable(input, attribute: kAXValueAttribute as String)
else {
Expand Down Expand Up @@ -294,6 +301,44 @@ private func perform(_ target: PressTarget) -> BackgroundDeliveryMechanism? {
}
}

/// The app's accessibility tree also holds its menu bar, whose Apple menu acts for the whole Mac
/// (Sleep, Lock Screen, Recent Items). A point action therefore lands only inside one of the app's
/// own on-screen windows, its menus and sheets included, and never on a menu bar element: the app
/// owns its menu bar's window too, so the window check alone does not exclude it.
private func requireAppWindowPoint(_ point: CGPoint, app: NSRunningApplication) throws {
guard
let info = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID)
as? [[String: Any]]
else {
throw refusal(.noAccessibleTarget, "the app's on-screen windows could not be read", app: app)
}
let inAppWindow = info.contains { entry in
guard (entry[kCGWindowOwnerPID as String] as? Int32) == app.processIdentifier,
let boundsDict = entry[kCGWindowBounds as String] as? NSDictionary,
let bounds = CGRect(dictionaryRepresentation: boundsDict as CFDictionary)
else {
return false
}
return bounds.contains(point)
}
guard inAppWindow else {
throw refusal(.noAccessibleTarget, "the point is outside the app's on-screen windows", app: app)
}
}

private func isInMenuBar(_ element: AXUIElement) -> Bool {
var current: AXUIElement? = element
var depth = 0
while let node = current, depth < 32 {
let nodeRole = role(of: node)
if nodeRole == "AXMenuBar" || nodeRole == "AXMenuBarItem" { return true }
if nodeRole == "AXApplication" { return false }
current = elementAttribute(node, attribute: kAXParentAttribute as String)
depth += 1
}
return false
}

/// The app's own hit test, scoped to the app so windows of other apps above it do not answer.
private func elementAtPoint(in app: NSRunningApplication, point: CGPoint) -> AXUIElement? {
let appElement = AXUIElementCreateApplication(app.processIdentifier)
Expand Down
29 changes: 29 additions & 0 deletions docs/adr/0007-remote-device-leases.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,35 @@ reconnect and re-establish them; no persisted hold store is used. Local takeover
future host-global human-control fence must coexist with the local session's device claim, not
acquire it exclusively.

## macOS app leases

A lease on the macOS host would rent the whole desktop, so the macOS platform rents one app instead.
A `macos-app` lease's device key is a bundle id, optionally pinned to one process
(`<bundleId>` or `<bundleId>@<pid>`). The scope would be self-chosen if a tenant could name it, so
only a host administrator allocates one, over the loopback `/admin/leases` route that uses the daemon
token like host holds; tenant `lease_allocate` refuses the backend. The host picks the lease id, a
repeated PUT renews it, and a PUT naming another scope for an existing id is refused rather than
rewritten. Heartbeat, expiry, release, and the loss on daemon restart are those of any lease, except
that a tenant heartbeat or request cannot renew it for longer than the window of the host's last PUT.

Request admission confines every request admitted under the lease, so `batch` steps and `replay`
actions are confined when they re-enter it: an allow list of commands, the ones whose command
registry descriptor declares `appLease: 'allowed'` (later commands are refused, and of the commands
lease admission otherwise exempts only `lease_heartbeat` and `lease_release` declare it),
`open` and `close` of the leased bundle only, the `app` surface only, window-only screenshots, no
input that names a host path or launches beside the app, and an existing session that is the leased
app for every request but `open`, the `batch` envelope, and the lease's heartbeat and release, so a
request naming no session cannot fall back to the host Mac. `open` requires the native app backend (ADR 0031), because XCTest
posts screen events that can land outside the app's window. A pid-pinned lease is checked against the
running process before each admitted request. A session opened under the lease holds its app, not
the Mac: it takes no host device claim, and other app-leased sessions on the same Mac do not conflict
with it, so one daemon serves several leased apps beside the host's own sessions.

These rules bind a request that names the lease or runs in its session. A daemon policy
`leases.require` (ADR 0029) refuses requests that name no lease. The proxy token is one credential
for every client, so a host serving several clients through one proxy authenticates each client and
sets its tenant, session isolation, and lease on every request it forwards.

## Host managed-device durability amendment

ADR 0021 adds a narrow durability exception for Host leases backed by a managed-device allocator.
Expand Down
10 changes: 8 additions & 2 deletions docs/adr/0029-daemon-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Accepted (2026-09-29).
command asks for it. The one exception is Apple readiness rolling back a Simulator boot that
the same request started and then canceled: the Simulator was not running before.
4. A denial is `UNAUTHORIZED` with `details.reason: 'DAEMON_POLICY_DENIED'`, the `rule`
(`command`, `device`, or `capability`), the policy digest, `retriable: false`, and a hint. It
(`command`, `device`, `capability`, or `lease`), the policy digest, `retriable: false`, and a hint. It
never names the policy's host path.
5. The daemon publishes the policy digest in `daemon.json`. A client that names a policy refuses to
reuse a daemon that enforces a different one, or none. A client that names no policy reuses
Expand All @@ -36,7 +36,8 @@ Accepted (2026-09-29).
"version": 1,
"devices": { "allow": [{ "udid": "8F1C…" }, { "serial": "emulator-5554" }] },
"commands": { "deny": ["boot", "shutdown"] },
"capabilities": { "deny": ["device-shutdown"] }
"capabilities": { "deny": ["device-shutdown"] },
"leases": { "require": "macos-app" }
}
```

Expand All @@ -56,6 +57,11 @@ Accepted (2026-09-29).
upgrade are denied by default.
- `capabilities.deny` — operations denied whichever command reaches them. `device-shutdown` is the
only capability today.
- `leases.require` — `macos-app`, the only accepted value. Every request must be admitted under a
`macos-app` lease and pass its command allow list (ADR 0007), including commands lease admission
otherwise exempts; of those, only `lease_heartbeat` and `lease_release` pass. A request naming no
lease is refused instead of being admitted unleased, with `rule: 'lease'`. A host that confines
clients to `macos-app` leases sets it so a client cannot drop its lease to reach the desktop.

Unknown keys are errors, so a misspelled rule cannot silently become no rule.

Expand Down
6 changes: 4 additions & 2 deletions docs/adr/0031-macos-native-app-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,10 @@ were accepted by Calculator and by Electron apps in the background. A fallback t
observed to work would turn "nothing happened" into success, so pointer actions have no event
fallback.

**Target resolution.** The helper hit-tests inside the session app (other apps' windows above it do
not answer) and walks at most four ancestors for a text input or a pressable control role.
**Target resolution.** The helper acts only on a point inside one of the session app's own on-screen
windows (its menus and sheets included) and never on its menu bar, whose Apple menu acts for the whole Mac
(Sleep, Lock Screen, Recent Items); other points are refused with `no-accessible-target`. It
hit-tests inside the session app (other apps' windows above it do not answer) and walks at most four ancestors for a text input or a pressable control role.
Chromium answers a hit test with wrapper groups that all claim `AXPress`, so when the chain names
no control the helper picks the smallest such element whose frame contains the point, searching
only the window the hit landed in (the app's front on-screen window when the hit names none). A
Expand Down
6 changes: 6 additions & 0 deletions packages/command-registry/src/daemon-command-descriptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ export type DaemonCommandDescriptor<TRequest = DispatchedCommand> = {
sessionKind?: SessionCommandKind;
refFrameEffect?: DaemonRefFrameEffect<TRequest>;
leaseAdmissionExempt?: boolean;
/**
* ADR 0007: this command may run under a `macos-app` lease, or on a daemon whose policy requires
* one. A command without it is refused there, `leaseAdmissionExempt` or not, so a command
* added later stays out until it opts in.
*/
appLease?: 'allowed';
sessionExecutionLockExempt?: boolean;
selectorValidationExempt?: boolean;
replayScopedAction?: boolean;
Expand Down
5 changes: 3 additions & 2 deletions packages/command-registry/src/flag-definitions-connection.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { LEASE_BACKENDS } from '@agent-device/kernel/contracts';
import { PROVIDER_DEVICE_ORIENTATIONS } from '@agent-device/contracts/remote';
import type { FlagDefinition } from './flag-types.ts';

Expand Down Expand Up @@ -131,8 +132,8 @@ export const CONNECTION_FLAG_DEFINITIONS: readonly FlagDefinition[] = [
key: 'leaseBackend',
names: ['--lease-backend'],
type: 'enum',
enumValues: ['ios-simulator', 'ios-instance', 'android-instance', 'harmonyos-instance'],
usageLabel: '--lease-backend ios-simulator|ios-instance|android-instance|harmonyos-instance',
enumValues: LEASE_BACKENDS,
usageLabel: `--lease-backend ${LEASE_BACKENDS.join('|')}`,
usageDescription: 'Lease backend for remote tenant connection admission',
projectConfig: false,
recorded: false,
Expand Down
23 changes: 17 additions & 6 deletions packages/command-registry/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,7 @@ export const RAW_COMMAND_DESCRIPTORS = [
route: 'lease',
refFrameEffect: 'preserve',
...ADMISSION_AND_LOCK_EXEMPT,
appLease: 'allowed',
},
timeoutPolicy: LEASE_TIMEOUT_POLICY,
batchable: false,
Expand All @@ -563,6 +564,7 @@ export const RAW_COMMAND_DESCRIPTORS = [
route: 'lease',
refFrameEffect: 'preserve',
...ADMISSION_AND_LOCK_EXEMPT,
appLease: 'allowed',
},
timeoutPolicy: LEASE_TIMEOUT_POLICY,
batchable: false,
Expand Down Expand Up @@ -1025,6 +1027,7 @@ export const RAW_COMMAND_DESCRIPTORS = [
refFrameEffect: 'may-invalidate',
allowSessionlessDefaultDevice: allowAnyDeviceSessionless,
saveScriptFlagOwner: true,
appLease: 'allowed',
},
// --timeout is a startup budget: it reaches the Simulator boot wait (#2324).
timeoutPolicy: { ...DEFAULT_TIMEOUT_POLICY, budget: { source: 'flag', envelope: 'margin' } },
Expand Down Expand Up @@ -1057,7 +1060,7 @@ export const RAW_COMMAND_DESCRIPTORS = [
catalog: { group: 'public' },
frameworkTier: 'extended',
recordsSessionAction: false,
daemon: { route: 'session', refFrameEffect: 'delegated' },
daemon: { route: 'session', refFrameEffect: 'delegated', appLease: 'allowed' },
timeoutPolicy: DEFAULT_TIMEOUT_POLICY,
batchable: false,
// Wave 6 residue: every step runs as its own daemon request under its own descriptor, which
Expand All @@ -1078,6 +1081,7 @@ export const RAW_COMMAND_DESCRIPTORS = [
allowInvalidRecording: true,
saveScriptFlagOwner: true,
sessionlessLeaseAdmissionExemption: resolvePlainCloseLeaseAdmissionExemption,
appLease: 'allowed',
},
timeoutPolicy: DEFAULT_TIMEOUT_POLICY,
batchable: true,
Expand All @@ -1093,7 +1097,7 @@ export const RAW_COMMAND_DESCRIPTORS = [
frameworkTier: 'core',
recordsSessionAction: true,
recordingEffect: 'observes-app',
daemon: { route: 'snapshot', refFrameEffect: 'preserve' },
daemon: { route: 'snapshot', refFrameEffect: 'preserve', appLease: 'allowed' },
// First Apple snapshot on a device can sit behind runner startup; --timeout
// widens the envelope, and a timeout must not tear down the daemon.
timeoutPolicy: { ...PRESERVE_DAEMON_TIMEOUT_POLICY, budget: { source: 'flag' } },
Expand Down Expand Up @@ -1132,7 +1136,7 @@ export const RAW_COMMAND_DESCRIPTORS = [
// #1349: a wait's landmark may legitimately be absent when the step
// starts, so identity verification runs inside its polling resolution.
targetIdentityVerification: 'post-resolution',
daemon: { route: 'snapshot', refFrameEffect: 'preserve' },
daemon: { route: 'snapshot', refFrameEffect: 'preserve', appLease: 'allowed' },
// The wait budget travels as a positional, not a flag; parse it the same
// way the daemon will so the request envelope extends past it (#1075).
timeoutPolicy: {
Expand Down Expand Up @@ -1271,6 +1275,7 @@ export const RAW_COMMAND_DESCRIPTORS = [
daemon: {
route: 'find',
refFrameEffect: 'may-invalidate',
appLease: 'allowed',
},
timeoutPolicy: PRESERVE_DAEMON_TIMEOUT_POLICY,
batchable: true,
Expand Down Expand Up @@ -1301,6 +1306,7 @@ export const RAW_COMMAND_DESCRIPTORS = [
route: 'interaction',
refFrameEffect: 'may-invalidate',
androidBlockingDialogGuard: true,
appLease: 'allowed',
},
timeoutPolicy: postActionObservationTimeoutPolicy('click', PRESERVE_DAEMON_TIMEOUT_POLICY),
postActionObservation: postActionObservation('click'),
Expand All @@ -1313,6 +1319,7 @@ export const RAW_COMMAND_DESCRIPTORS = [
name: 'fill',
...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}),
...TARGETED_TOUCH_INTERACTION_TRAITS,
daemon: { ...TARGETED_TOUCH_INTERACTION_TRAITS.daemon, appLease: 'allowed' },
frameworkTier: 'core',
timeoutPolicy: postActionObservationTimeoutPolicy('fill', PRESERVE_DAEMON_TIMEOUT_POLICY),
postActionObservation: postActionObservation('fill'),
Expand Down Expand Up @@ -1360,6 +1367,7 @@ export const RAW_COMMAND_DESCRIPTORS = [
name: 'press',
...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}),
...TARGETED_TOUCH_INTERACTION_TRAITS,
daemon: { ...TARGETED_TOUCH_INTERACTION_TRAITS.daemon, appLease: 'allowed' },
frameworkTier: 'core',
timeoutPolicy: postActionObservationTimeoutPolicy('press', PRESERVE_DAEMON_TIMEOUT_POLICY),
postActionObservation: postActionObservation('press'),
Expand All @@ -1380,6 +1388,7 @@ export const RAW_COMMAND_DESCRIPTORS = [
route: 'interaction',
refFrameEffect: 'may-invalidate',
androidBlockingDialogGuard: true,
appLease: 'allowed',
},
timeoutPolicy: postActionObservationTimeoutPolicy('type', PRESERVE_DAEMON_TIMEOUT_POLICY),
batchable: true,
Expand All @@ -1394,7 +1403,7 @@ export const RAW_COMMAND_DESCRIPTORS = [
frameworkTier: 'core',
recordsSessionAction: true,
recordingEffect: 'observes-app',
daemon: { route: 'interaction', refFrameEffect: 'preserve' },
daemon: { route: 'interaction', refFrameEffect: 'preserve', appLease: 'allowed' },
timeoutPolicy: postActionObservationTimeoutPolicy('get', PRESERVE_DAEMON_TIMEOUT_POLICY),
batchable: true,
platformExecution: { kind: 'device-runtime', uses: selectorTextCaptureRuntimePlanUses },
Expand All @@ -1408,7 +1417,7 @@ export const RAW_COMMAND_DESCRIPTORS = [
frameworkTier: 'core',
recordsSessionAction: true,
recordingEffect: 'observes-app',
daemon: { route: 'interaction', refFrameEffect: 'preserve' },
daemon: { route: 'interaction', refFrameEffect: 'preserve', appLease: 'allowed' },
timeoutPolicy: postActionObservationTimeoutPolicy('is', PRESERVE_DAEMON_TIMEOUT_POLICY),
batchable: true,
platformExecution: { kind: 'device-runtime', uses: selectorCaptureRuntimePlanUses },
Expand Down Expand Up @@ -1500,6 +1509,7 @@ export const RAW_COMMAND_DESCRIPTORS = [
// the owner's `scrollDirection` fact, and the only execution is the bound operation. `scroll`
// was the last holder of the legacy `dispatch`/`capability` pair, which retires with it.
...GENERIC_MUTATING_COMMAND_TRAITS,
daemon: { ...GENERIC_MUTATING_COMMAND_TRAITS.daemon, appLease: 'allowed' },
timeoutPolicy: postActionObservationTimeoutPolicy('scroll', DEFAULT_TIMEOUT_POLICY),
postActionObservation: postActionObservation('scroll'),
platformExecution: { kind: 'device-runtime', uses: scrollRuntimePlanUses },
Expand Down Expand Up @@ -1531,6 +1541,7 @@ export const RAW_COMMAND_DESCRIPTORS = [
// R40 retires this command's capability bucket and its `dispatch` leaf together: admission is
// the owner's `focusPoint` fact, and the only execution is the bound operation.
...GENERIC_MUTATING_COMMAND_TRAITS,
daemon: { ...GENERIC_MUTATING_COMMAND_TRAITS.daemon, appLease: 'allowed' },
platformExecution: { kind: 'device-runtime', uses: [focusRuntimeUse] },
},
{
Expand All @@ -1548,7 +1559,7 @@ export const RAW_COMMAND_DESCRIPTORS = [
frameworkTier: 'core',
recordsSessionAction: true,
recordingEffect: 'observes-app',
daemon: { route: 'generic', refFrameEffect: 'preserve' },
daemon: { route: 'generic', refFrameEffect: 'preserve', appLease: 'allowed' },
timeoutPolicy: DEFAULT_TIMEOUT_POLICY,
batchable: true,
platformExecution: { kind: 'device-runtime', uses: screenshotRuntimePlanUses },
Expand Down
10 changes: 9 additions & 1 deletion packages/contracts/src/daemon-http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,20 @@ export type DaemonHealthPayload = {
rpcProtocolVersion: number;
instanceId?: string;
hostArch?: string;
/** The lease backends this daemon admits; a host checks it before relying on one. */
leaseBackends?: readonly string[];
upstream?: unknown;
};

export function buildDaemonHealthPayload(
service: DaemonHealthPayload['service'],
version: string,
options: { upstream?: unknown; instanceId?: string; hostArch?: string } = {},
options: {
upstream?: unknown;
instanceId?: string;
hostArch?: string;
leaseBackends?: readonly string[];
} = {},
): DaemonHealthPayload {
return {
ok: true,
Expand All @@ -72,6 +79,7 @@ export function buildDaemonHealthPayload(
rpcProtocolVersion: DAEMON_RPC_PROTOCOL_VERSION,
...(options.instanceId !== undefined ? { instanceId: options.instanceId } : {}),
...(options.hostArch !== undefined ? { hostArch: options.hostArch } : {}),
...(options.leaseBackends !== undefined ? { leaseBackends: options.leaseBackends } : {}),
...(options.upstream !== undefined ? { upstream: options.upstream } : {}),
};
}
Loading
Loading