diff --git a/.fallowrc.json b/.fallowrc.json index 324731af61..d48cd0a159 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -567,15 +567,7 @@ "exports": ["CommandPolicy", "CommandSessionRecord"] } ], - "usedClassMembers": [ - "name", - "listActiveLeases", - "delete", - "values", - "elapsedMs", - "isExpired", - "findByDevice" - ], + "usedClassMembers": ["name", "listActiveLeases", "delete", "values", "elapsedMs", "isExpired"], "rules": { "unused-types": "error", "duplicate-exports": "off" diff --git a/docs/adr/0007-remote-device-leases.md b/docs/adr/0007-remote-device-leases.md index dee137a90c..5efd1171bc 100644 --- a/docs/adr/0007-remote-device-leases.md +++ b/docs/adr/0007-remote-device-leases.md @@ -189,8 +189,9 @@ app for every request but `open` and the `batch` envelope, 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. +the Mac: it takes no host device claim, and sessions on other apps of the same Mac, leased or +holding an app claim (ADR 0034), do not conflict with it, so one daemon serves several leased apps +beside the host's own sessions. A session on the same app does. 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 diff --git a/docs/adr/0031-macos-native-app-backend.md b/docs/adr/0031-macos-native-app-backend.md index 228bb88980..cbe9235a9a 100644 --- a/docs/adr/0031-macos-native-app-backend.md +++ b/docs/adr/0031-macos-native-app-backend.md @@ -36,6 +36,8 @@ window fields are unmeasured. assistive client. The app surface walks up to 48 levels deep and reports a deeper tree as truncated; other helper surfaces keep 12. 6. Screenshots capture the session app's front window by itself through ScreenCaptureKit. +7. An app session claims only its app, and `open` launches or reopens that app with `open -g`, + leaving the frontmost app in front ([ADR 0034](0034-macos-per-app-claims.md)). ## Context diff --git a/docs/adr/0034-macos-per-app-claims.md b/docs/adr/0034-macos-per-app-claims.md new file mode 100644 index 0000000000..af4acca16e --- /dev/null +++ b/docs/adr/0034-macos-per-app-claims.md @@ -0,0 +1,83 @@ +# ADR 0034: macOS Per-App Device Claims + +## Status + +Accepted (2026-10-08). Applies to the native macOS app backend (ADR 0031) only. Numbered 0034 +because 0032 and 0033 exist on main. + +## Rules at a glance + +1. A native macOS `app` session whose app resolves to a bundle id before launch claims + `:app:` instead of the device key. Every other macOS session (XCTest, + `desktop`, `menubar`, `frontmost-app`, an open of a URL alone) claims the device key. A + `macos-app` lease still takes no claim (ADR 0007). +2. The per-app record is the process-owned v2 record with `app: { bundleId }`; it decodes only + at the key derived from its identity and app. Classification, takeover, close, idle expiry, + shutdown, the startup sweep, and `device release --stale` treat it like any claim. +3. Lock order is device key, then app key. Every acquisition on a device holds the device key's + lock; a per-app acquisition takes the app key's lock inside it. Renew, clear, abandon, the + sweep, and stale release take only their own key's lock and never create a claim. +4. A per-app acquisition settles the device key's claim first: a live foreign whole-device claim + is a conflict. A whole-device acquisition on a macOS device scans the per-app claims of that + device: dead or superseded owners are reconciled and removed, other foreign claims conflict, + and this daemon's own are left to its session store. +5. In one daemon, a session holds either one app (an app claim, or a `macos-app` lease, which + takes no claim) or the whole device. Two sessions on the device conflict unless each holds one + app and the apps differ. The new-session conflict check and the `open --wait` that queues on it + decide with this one predicate, so a whole-device opener and a same-app opener wait for an app + session and a different-app opener runs beside it. A per-app session may reopen only its app on + the `app` surface; a link opens as `open `, because a bare URL's handler is not known + before launch. +6. Helper screenshots take a host-wide lock (`macos-screen-capture.lock` in the device claims + directory) for the length of one capture. It is the only action sessions on different apps + serialize on. +7. App keys are lowercase, and the predicate of rule 5 compares bundle ids without regard to case, + because LaunchServices matches them that way. An `open --wait` resolves the claim it would take + under the device lock; until then it waits only for a whole-device session, and when the open + then finds a conflicting app session it releases the lock and waits for that session too. + +## Context + +The Mac is one device, so its one claim let a single session per Mac drive any macOS app. The +native backend acts on the session app alone: accessibility actions on its elements, key events +posted to its process, window capture, and (rule 7 of ADR 0031) a background `open`. None of +those moves the real pointer or changes the frontmost app, so two sessions on two apps cannot +interfere through focus or the pointer. One host resource is shared: ScreenCaptureKit. Two helper +processes capturing two app windows at the same moment failed (`screenshot failed`, or both hung +until the 30 s helper timeout) in each of four attempts on macOS 27, while each capture alone took +about 0.5 s, so captures run one at a time. + +## Decision details + +**Bundle id, not pid.** The helper resolves its target app by bundle id, so two processes of one +bundle id are one target to every action. A pid in the key would let two sessions drive the same +windows. Liveness is the owning daemon's process, as for every claim. Two copies of one app need +two bundle ids. + +**Key resolved before the claim.** The bundle id comes from the read-only open-target resolution, +before the claim and before launch. An open whose app cannot be resolved that early takes the +whole device rather than guessing. + +**What stays whole-Mac.** Surfaces other than `app` post real pointer events or read every app. +Some commands still reach past the session app, as they did before this ADR: the clipboard and +appearance settings (host-global state), `close ` (quits any app, including one another +daemon's session holds), and `settings permission` (opens System Settings in front). + +**Background open follows the session's scope.** A session that holds one app opens it with +`open -g`, because bringing it forward would move the frontmost app under every other session. +A session that holds the whole Mac, including `open --surface frontmost-app`, opens the app in +front as before. + +## Rejected alternatives + +- **An input lock around every action.** The native actions need neither focus nor the pointer + once `open` stops activating, so a lock would only serialize sessions for nothing. Only the + capture, which contends on ScreenCaptureKit rather than on input, is serialized. +- **A separate claims directory per app.** Two stores could not exclude each other under one + lock, and every stale-claim path would need a second scan. + +## Support boundary + +A daemon older than this ADR reads a per-app record as inconsistent and never clears it, and its +whole-device acquisition does not scan per-app records. Run one version per claims directory, as +ADR 0030 requires for process locks. diff --git a/docs/adr/README.md b/docs/adr/README.md index f6dd2a76a5..1b9fa005bd 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -35,6 +35,7 @@ | [0031 macOS Native App Backend](0031-macos-native-app-backend.md) | `AGENT_DEVICE_MACOS_APP_BACKEND`, driving macOS app sessions without XCTest Automation Mode, why pointer actions are accessibility actions only, and the ghost cursor | | [0032 Layering Graph Engine](0032-layering-graph-engine.md) | evaluating replacement of `scripts/layering/` graph rules with dependency-cruiser or fallow `boundaries`, why graph-based layering checks share one replaceable import model, the measured edge-set and parity gaps, and the revisit triggers | | [0033 Collocation decision table](0033-collocation-decision-table.md) | moving or adding a file listed in umbrella #3276 §5, re-proposing a move that table refused, reading a zone's collocation decisions | +| [0034 macOS Per-App Device Claims](0034-macos-per-app-claims.md) | claim keys below a device, the device-then-app lock order, native macOS app sessions running beside each other, stale per-app claims | ADRs record *why*; the registries and gates they describe are the living source of truth — when prose and a registry disagree, the registry wins and the ADR needs a follow-up. diff --git a/packages/contracts/src/application-lifecycle-interaction.ts b/packages/contracts/src/application-lifecycle-interaction.ts index d179cbe202..6502befd94 100644 --- a/packages/contracts/src/application-lifecycle-interaction.ts +++ b/packages/contracts/src/application-lifecycle-interaction.ts @@ -141,6 +141,7 @@ export async function invokeApplicationOpen( appBundleId?: string; execution: ApplicationLifecycleExecution; terminateRunningApp?: boolean; + background?: boolean; }>, ): Promise { const { device, positionals, execution } = params; @@ -166,6 +167,7 @@ type DirectOpenParameters = Readonly<{ appBundleId?: string; execution: ApplicationLifecycleExecution; terminateRunningApp?: boolean; + background?: boolean; }>; function assertOpenPositionals(positionals: readonly string[]): void { @@ -219,6 +221,7 @@ async function invokeApplicationUrlOpen( launchArgs: params.execution.launchArgs ? [...params.execution.launchArgs] : undefined, terminateRunningApp: params.terminateRunningApp, url, + ...(params.background ? { background: true } : {}), }); } @@ -245,6 +248,7 @@ async function invokeApplicationTargetOpen( launchConsole: execution.launchConsole, launchArgs: execution.launchArgs ? [...execution.launchArgs] : undefined, terminateRunningApp: params.terminateRunningApp, + ...(params.background ? { background: true } : {}), }); } diff --git a/packages/contracts/src/application-lifecycle-runtime.ts b/packages/contracts/src/application-lifecycle-runtime.ts index 2fd7144220..db542a1adc 100644 --- a/packages/contracts/src/application-lifecycle-runtime.ts +++ b/packages/contracts/src/application-lifecycle-runtime.ts @@ -97,6 +97,8 @@ export type OpenApplicationInput = Readonly<{ runtimeLaunchUrl?: string; appBundleId?: string; surface: SessionSurface; + /** Launch or reopen without bringing the app to the front: the session holds this app alone. */ + background?: boolean; hasExistingSession: boolean; relaunch: boolean; prewarmRunnerBeforeOpen: boolean; diff --git a/packages/contracts/src/interactor-types.ts b/packages/contracts/src/interactor-types.ts index 73b727c97b..507660c195 100644 --- a/packages/contracts/src/interactor-types.ts +++ b/packages/contracts/src/interactor-types.ts @@ -295,6 +295,8 @@ export type Interactor = { launchArgs?: string[]; terminateRunningApp?: boolean; url?: string; + /** macOS: launch or reopen without bringing the app to the front. */ + background?: boolean; }, ): Promise; openDevice(): Promise; diff --git a/packages/platform-apple/src/core/app-launch.ts b/packages/platform-apple/src/core/app-launch.ts index a6e86b3cbe..bc9586773f 100644 --- a/packages/platform-apple/src/core/app-launch.ts +++ b/packages/platform-apple/src/core/app-launch.ts @@ -61,6 +61,8 @@ export async function openIosApp( terminateRunningApp?: boolean; url?: string; runnerOptions?: AppleRunnerCommandOptions; + /** macOS only: launch or reopen without bringing the app to the front. */ + background?: boolean; }, ): Promise { const launchConsole = options?.launchConsole?.trim(); diff --git a/packages/platform-apple/src/core/tool-provider-types.ts b/packages/platform-apple/src/core/tool-provider-types.ts index c7458d3edc..b7486bcb03 100644 --- a/packages/platform-apple/src/core/tool-provider-types.ts +++ b/packages/platform-apple/src/core/tool-provider-types.ts @@ -32,9 +32,12 @@ export type ApplePlistProvider = { readJson(path: string, signal?: AbortSignal): Promise | null>; }; +export type MacOsOpenOptions = { background?: boolean }; + export type AppleMacOsHostProvider = { - openBundle(bundleId: string, url?: string): Promise; - openTarget(target: string): Promise; + /** `background` launches or reopens without bringing the app to the front. */ + openBundle(bundleId: string, url?: string, options?: MacOsOpenOptions): Promise; + openTarget(target: string, options?: MacOsOpenOptions): Promise; readClipboard(): Promise; writeClipboard(text: string): Promise; readDarkMode(): Promise; diff --git a/packages/platform-apple/src/interactor.ts b/packages/platform-apple/src/interactor.ts index 3ca2da7d96..099b9ee65a 100644 --- a/packages/platform-apple/src/interactor.ts +++ b/packages/platform-apple/src/interactor.ts @@ -74,6 +74,7 @@ export function createAppleInteractor( launchArgs: options?.launchArgs, terminateRunningApp: options?.terminateRunningApp, url: options?.url, + background: options?.background, runnerOptions: runnerOpts, }), openDevice: () => openIosDevice(device), diff --git a/packages/platform-apple/src/lifecycle.ts b/packages/platform-apple/src/lifecycle.ts index 943b3a8cf3..061099cd86 100644 --- a/packages/platform-apple/src/lifecycle.ts +++ b/packages/platform-apple/src/lifecycle.ts @@ -223,6 +223,7 @@ async function dispatchAppleOpen( positionals: launch.positionals, appBundleId: input.appBundleId, execution: input.execution, + ...(input.background ? { background: true } : {}), ...(localIosSimulator && input.relaunch && !input.execution.clearAppState ? { terminateRunningApp: true } : {}), @@ -264,6 +265,7 @@ async function openLaunchUrl( positionals: [url], appBundleId: input.appBundleId, execution, + ...(input.background ? { background: true } : {}), }); } diff --git a/packages/platform-apple/src/os/macos/apps.test.ts b/packages/platform-apple/src/os/macos/apps.test.ts index 6a9361d577..133831a8c0 100644 --- a/packages/platform-apple/src/os/macos/apps.test.ts +++ b/packages/platform-apple/src/os/macos/apps.test.ts @@ -5,7 +5,8 @@ vi.mock('node:timers/promises', () => ({ setTimeout: vi.fn(async () => undefined vi.mock('./helper.ts', () => ({ quitMacOsApp: vi.fn() })); import { quitMacOsApp } from './helper.ts'; -import { closeMacOsApp } from './apps.ts'; +import { closeMacOsApp, openMacOsApp } from './apps.ts'; +import { createLocalAppleToolProvider, withAppleToolProvider } from '../../core/tool-provider.ts'; const MACOS_DEVICE: DeviceInfo = { platform: 'apple', @@ -76,3 +77,32 @@ test('closeMacOsApp bounds termination confirmation', async () => { }); expect(mockQuitMacOsApp).toHaveBeenCalledTimes(20); }); + +test('openMacOsApp leaves the frontmost app in front only for a background open', async () => { + const opened = async (background: boolean | undefined) => { + const calls: string[][] = []; + const provider = createLocalAppleToolProvider({ + runCommand: async (cmd, args) => { + if (cmd === 'open') calls.push(args); + return { exitCode: 0, stdout: '', stderr: '' }; + }, + }); + await withAppleToolProvider(provider, async () => { + await openMacOsApp(MACOS_DEVICE, 'com.example.demo', { background }); + await openMacOsApp(MACOS_DEVICE, 'com.example.demo', { url: 'demo://open', background }); + await openMacOsApp(MACOS_DEVICE, 'demo://open', { background }); + }); + return calls; + }; + + expect(await opened(true)).toEqual([ + ['-g', '-b', 'com.example.demo'], + ['-g', '-b', 'com.example.demo', 'demo://open'], + ['-g', 'demo://open'], + ]); + expect(await opened(undefined)).toEqual([ + ['-b', 'com.example.demo'], + ['-b', 'com.example.demo', 'demo://open'], + ['demo://open'], + ]); +}); diff --git a/packages/platform-apple/src/os/macos/apps.ts b/packages/platform-apple/src/os/macos/apps.ts index ec44a23c1f..b436d5aeec 100644 --- a/packages/platform-apple/src/os/macos/apps.ts +++ b/packages/platform-apple/src/os/macos/apps.ts @@ -60,26 +60,27 @@ export async function resolveMacOsApp(app: string): Promise { export async function openMacOsApp( _device: DeviceInfo, app: string, - options?: { appBundleId?: string; url?: string }, + options?: { appBundleId?: string; url?: string; background?: boolean }, ): Promise { + const openOptions = { background: options?.background === true }; const explicitUrl = options?.url?.trim(); if (explicitUrl) { if (!isDeepLinkTarget(explicitUrl)) { throw new AppError('INVALID_ARGS', 'open requires a valid URL target'); } const appId = options?.appBundleId ?? (await resolveMacOsApp(app)); - await resolveMacOsHostProvider().openBundle(appId, explicitUrl); + await resolveMacOsHostProvider().openBundle(appId, explicitUrl, openOptions); return; } const target = app.trim(); if (isDeepLinkTarget(target)) { - await resolveMacOsHostProvider().openTarget(target); + await resolveMacOsHostProvider().openTarget(target, openOptions); return; } const bundleId = options?.appBundleId ?? (await resolveMacOsApp(target)); - await resolveMacOsHostProvider().openBundle(bundleId); + await resolveMacOsHostProvider().openBundle(bundleId, undefined, openOptions); } export async function closeMacOsApp(_device: DeviceInfo, app: string): Promise { diff --git a/packages/platform-apple/src/os/macos/helper.test.ts b/packages/platform-apple/src/os/macos/helper.test.ts index 777c0caa92..bee89d7d71 100644 --- a/packages/platform-apple/src/os/macos/helper.test.ts +++ b/packages/platform-apple/src/os/macos/helper.test.ts @@ -241,6 +241,32 @@ test('macOS helper screenshot argv carries only --out and --surface', async () = assert.deepEqual(receivedArgs, ['screenshot', '--out', '/tmp/out.png', '--surface', 'desktop']); }); +test('macOS helper screenshots never run two captures at once', async () => { + let active = 0; + let maxActive = 0; + const provider = createLocalAppleToolProvider({ + macosHelper: { + run: async () => { + active += 1; + maxActive = Math.max(maxActive, active); + await new Promise((resolve) => setTimeout(resolve, 10)); + active -= 1; + return helperReturn({ path: '/tmp/out.png', surface: 'app' }); + }, + }, + }); + const app = macOsHelperSurface('app', 'native')!; + + await withAppleToolProvider(provider, async () => { + await Promise.all([ + runMacOsScreenshotAction('/tmp/one.png', { surface: app, bundleId: 'com.example.one' }), + runMacOsScreenshotAction('/tmp/two.png', { surface: app, bundleId: 'com.example.two' }), + ]); + }); + + assert.equal(maxActive, 1); +}); + test('helper entry points accept only an owner-routed surface', () => { // Never invoked: each directive fails typecheck once its entry point widens back to an // unbranded or optional surface. diff --git a/packages/platform-apple/src/os/macos/helper.ts b/packages/platform-apple/src/os/macos/helper.ts index 97cc51f146..8cd2d93d6f 100644 --- a/packages/platform-apple/src/os/macos/helper.ts +++ b/packages/platform-apple/src/os/macos/helper.ts @@ -17,6 +17,7 @@ import { } from '@agent-device/host-kit/host-file'; import { hostPlatform, + readCurrentOwnerIdentity, readHostEnvironmentVariable, writeHostStderr, } from '@agent-device/host-kit/process'; @@ -581,5 +582,33 @@ export async function runMacOsScreenshotAction( }> { const args = ['screenshot', '--out', outPath]; appendMacOsHelperContextArgs(args, options); - return await runMacOsHelper(args, { signal: options.signal }); + return await withMacOsScreenCaptureLock( + async () => await runMacOsHelper(args, { signal: options.signal }), + ); +} + +/** + * ScreenCaptureKit fails or hangs window captures that two processes start at the same time + * (measured on macOS 27 with two helpers capturing two apps), so every helper capture on this Mac + * runs alone, whichever daemon asks. The lock sits in the host-global device claims directory, + * the store every daemon on the host already coordinates through. + */ +async function withMacOsScreenCaptureLock(task: () => Promise): Promise { + const lockRoot = + readHostEnvironmentVariable('AGENT_DEVICE_CLAIMS_DIR')?.trim() || + path.join(hostHomeDirectory(), '.agent-device', 'device-claims'); + await ensureHostDirectory(lockRoot); + const { acquireProcessLock, withProcessLock } = await import('@agent-device/host-kit/file'); + return await withProcessLock({ + acquire: async () => { + const owner = readCurrentOwnerIdentity(); + return await acquireProcessLock({ + lockDirPath: path.join(path.resolve(lockRoot), 'macos-screen-capture.lock'), + owner: { pid: owner.pid, startTime: owner.startTime, acquiredAtMs: Date.now() }, + timeoutMs: MACOS_HELPER_TIMEOUT_MS + 5_000, + description: 'the macOS screen capture lock', + }); + }, + task, + }); } diff --git a/packages/platform-apple/src/os/macos/host-provider.ts b/packages/platform-apple/src/os/macos/host-provider.ts index 710fb25a34..ebbe20f0cc 100644 --- a/packages/platform-apple/src/os/macos/host-provider.ts +++ b/packages/platform-apple/src/os/macos/host-provider.ts @@ -8,6 +8,7 @@ import type { IosAppInfo } from '../../core/app-info.ts'; import type { AppleMacOsHostProvider, AppleToolCommandExecutor, + MacOsOpenOptions, } from '../../core/tool-provider-types.ts'; type ApplePlistJsonReader = (plistPath: string) => Promise | null>; @@ -17,11 +18,14 @@ export function createLocalAppleMacOsHostProvider( readPlistJson: ApplePlistJsonReader, ): AppleMacOsHostProvider { return { - openBundle: async (bundleId, url) => { - await runCommand('open', buildMacOpenArgs(bundleId, url)); + openBundle: async (bundleId, url, options) => { + await runCommand('open', [ + ...openActivationArgs(options), + ...buildMacOpenArgs(bundleId, url), + ]); }, - openTarget: async (target) => { - await runCommand('open', [target]); + openTarget: async (target, options) => { + await runCommand('open', [...openActivationArgs(options), target]); }, readClipboard: async () => { const result = requireExecSuccess( @@ -65,6 +69,10 @@ export function createLocalAppleMacOsHostProvider( }; } +function openActivationArgs(options: MacOsOpenOptions | undefined): string[] { + return options?.background ? ['-g'] : []; +} + function buildMacOpenArgs(bundleId: string, url?: string): string[] { const openArgs = ['-b', bundleId]; if (url) { diff --git a/scripts/layering/session-state.test.ts b/scripts/layering/session-state.test.ts index c2c12bdb12..289ddda985 100644 --- a/scripts/layering/session-state.test.ts +++ b/scripts/layering/session-state.test.ts @@ -298,7 +298,6 @@ test('known SessionRef-returning store methods seed computed destructuring', () for (const read of [ 'store.lookup(address)', 'store.publish(address, session)', - 'store.findByDevice(deviceId)', 'store.refresh(ref)', ]) { assert.deepEqual( diff --git a/scripts/layering/session-state.ts b/scripts/layering/session-state.ts index 1388abc843..57967c00c8 100644 --- a/scripts/layering/session-state.ts +++ b/scripts/layering/session-state.ts @@ -317,7 +317,7 @@ function isSessionRefValue( return ( node.type === 'CallExpression' && callee?.type === 'MemberExpression' && - ['lookup', 'publish', 'findByDevice', 'refresh'].includes(memberName(callee) ?? '') && + ['lookup', 'publish', 'refresh'].includes(memberName(callee) ?? '') && isSessionStoreReceiver(callee.object, storeBindings) ); } diff --git a/src/daemon/__tests__/application-lifecycle-runtime-fixture.ts b/src/daemon/__tests__/application-lifecycle-runtime-fixture.ts index 8e061f1d15..18a77e67c5 100644 --- a/src/daemon/__tests__/application-lifecycle-runtime-fixture.ts +++ b/src/daemon/__tests__/application-lifecycle-runtime-fixture.ts @@ -21,6 +21,7 @@ type LifecycleEffectContext = ApplicationLifecycleExecution & appBundleId?: string; signal: AbortSignal; terminateRunningApp?: boolean; + background?: boolean; }>; /** @@ -242,6 +243,7 @@ export function applicationLifecycleFixtureInteractor( launchArgs: options?.launchArgs, launchConsole: options?.launchConsole, terminateRunningApp: options?.terminateRunningApp, + background: options?.background, }), ); clearAppState = false; @@ -279,6 +281,7 @@ function lifecycleEffectContext( launchArgs?: readonly string[]; launchConsole?: string; terminateRunningApp?: boolean; + background?: boolean; }>, ): LifecycleEffectContext { return { @@ -299,5 +302,6 @@ function lifecycleEffectContext( ...(input.terminateRunningApp === undefined ? {} : { terminateRunningApp: input.terminateRunningApp }), + ...(input.background ? { background: true } : {}), }; } diff --git a/src/daemon/__tests__/open-device-contention-wait.test.ts b/src/daemon/__tests__/open-device-contention-wait.test.ts index 9c0bc8ac5d..9155b5f8a1 100644 --- a/src/daemon/__tests__/open-device-contention-wait.test.ts +++ b/src/daemon/__tests__/open-device-contention-wait.test.ts @@ -13,6 +13,7 @@ import { describeOpenWaitForRefusal, readOpenWaitAttempt, readOpenWaitBudgetMs, + sessionConflictsWithOpen, } from '../open-device-contention-wait.ts'; // `--wait ` is the one answer to a device another session is holding. Two things make that @@ -175,7 +176,7 @@ test('only a fresh open with a budget and a resolved device gets a wait', () => test('a free device is read once in the store and records no wait', async () => { const req = openRequest({ waitMs: 30_000 }); const store = makeSessionStore('agent-device-open-wait-'); - const looks = vi.spyOn(store, 'findByDevice'); + const looks = vi.spyOn(store, 'listRefs'); const wait = beginWait({ req, @@ -258,7 +259,7 @@ test('a budget that runs out busy costs the whole budget, then lets the open ref vi.useFakeTimers(); const req = openRequest({ waitMs: 1000 }); const store = storeWithHolder(); - const looks = vi.spyOn(store, 'findByDevice'); + const looks = vi.spyOn(store, 'listRefs'); const wait = beginWait({ req, sessionName: OPENER_ADDRESS, @@ -316,3 +317,134 @@ async function waitUntil(start: () => Promise, budgetMs: number): Promise< await vi.advanceTimersByTimeAsync(budgetMs); await running; } + +const HOST_MAC = { + platform: 'apple', + appleOs: 'macos', + id: 'host-macos-local', + name: 'Host Mac', + kind: 'device', + target: 'desktop', + booted: true, +} as const; + +/** A store whose holder drives `com.example.one` alone, through an app claim. */ +function storeWithAppHolder(): SessionStore { + const store = makeSessionStore('agent-device-open-wait-'); + store.publish(HOLDER_ADDRESS, { + ...session(HOLDER_ADDRESS), + device: HOST_MAC, + deviceClaim: { + deviceKey: 'local:apple:macos:host-macos-local:app:com.example.one', + ownerToken: 'token', + ownerPid: process.pid, + ownerStartTime: null, + app: { bundleId: 'com.example.one' }, + }, + }); + return store; +} + +/** + * An open that resolves its claim under the locks, as the new-session open does: it reports a + * conflicting session to the wait and refuses, or opens. + */ +function openClaiming(req: DaemonRequest, store: SessionStore, openerApp?: { bundleId: string }) { + return async () => { + const conflict = store + .listRefs() + .some( + (ref) => ref.address !== OPENER_ADDRESS && sessionConflictsWithOpen(ref.session, openerApp), + ); + if (!conflict) return 'opened'; + req.internal?.reportOpenDeviceConflict?.(openerApp); + return 'refused'; + }; +} + +for (const { opener, openerApp, waits } of [ + { opener: 'a whole-Mac opener', openerApp: undefined, waits: true }, + { opener: 'a same-app opener', openerApp: { bundleId: 'COM.example.one' }, waits: true }, + { opener: 'a different-app opener', openerApp: { bundleId: 'com.example.two' }, waits: false }, +]) { + test(`${opener} ${waits ? 'waits for' : 'runs beside'} a session holding one app`, async () => { + vi.useFakeTimers(); + const req = openRequest({ waitMs: 30_000 }); + const store = storeWithAppHolder(); + const holder = store.lookup(HOLDER_ADDRESS)!; + setTimeout(() => store.retire(holder), 600); + const wait = beginWait({ + req, + sessionName: OPENER_ADDRESS, + sessionStore: store, + deviceId: HOST_MAC.id, + })!; + await wait.waitForDeviceOutsideLocks(); + const trace = lockTrace({}); + const startedAtMs = Date.now(); + + const running = wait + .runWhenDeviceIsUnheld({ + acquireLocks: trace.acquireLocks, + task: openClaiming(req, store, openerApp), + }) + .then((outcome) => ({ outcome, waitedMs: Date.now() - startedAtMs })); + await vi.advanceTimersByTimeAsync(1_000); + + const { outcome, waitedMs } = await running; + expect(outcome).toBe('opened'); + expect(trace.order).toEqual( + waits ? ['acquire', 'release', 'acquire', 'release'] : ['acquire', 'release'], + ); + expect(waitedMs >= 600).toBe(waits); + }); +} + +test('a whole-Mac opener whose budget runs out beside an app session reports the wait', async () => { + vi.useFakeTimers(); + const req = openRequest({ waitMs: 1000 }); + const store = storeWithAppHolder(); + const wait = beginWait({ + req, + sessionName: OPENER_ADDRESS, + sessionStore: store, + deviceId: HOST_MAC.id, + })!; + await wait.waitForDeviceOutsideLocks(); + + const running = wait.runWhenDeviceIsUnheld({ + acquireLocks: lockTrace({}).acquireLocks, + task: openClaiming(req, store), + }); + await vi.advanceTimersByTimeAsync(10_000); + + expect(await running).toBe('refused'); + expect(readOpenWaitAttempt(req).waitedMs).toBe(1000); +}); + +test('the wait spend is readable from the request a lease admission copied after the spend', async () => { + vi.useFakeTimers(); + const req = openRequest({ waitMs: 1000 }); + const store = storeWithAppHolder(); + const wait = beginWait({ + req, + sessionName: OPENER_ADDRESS, + sessionStore: store, + deviceId: HOST_MAC.id, + })!; + await wait.waitForDeviceOutsideLocks(); + + let admitted = req; + const running = wait.runWhenDeviceIsUnheld({ + acquireLocks: lockTrace({}).acquireLocks, + task: async () => { + admitted = { ...admitted, internal: { ...admitted.internal } }; + return await openClaiming(admitted, store)(); + }, + }); + await vi.advanceTimersByTimeAsync(10_000); + + expect(await running).toBe('refused'); + expect(admitted).not.toBe(req); + expect(readOpenWaitAttempt(admitted).waitedMs).toBe(1000); +}); diff --git a/src/daemon/__tests__/request-execution-scope-open-device-wait.test.ts b/src/daemon/__tests__/request-execution-scope-open-device-wait.test.ts index b533eedfd2..25b2f321cb 100644 --- a/src/daemon/__tests__/request-execution-scope-open-device-wait.test.ts +++ b/src/daemon/__tests__/request-execution-scope-open-device-wait.test.ts @@ -94,7 +94,9 @@ function openOnUncontendedDevice( releaseAfterMs?: number, ): Promise { return scope.runLocked(async () => { - const currentOwner = sessionStore.findByDevice(CONTESTED_DEVICE.id); + const currentOwner = sessionStore + .listRefs() + .find((ref) => ref.session.device.id === CONTESTED_DEVICE.id); if (currentOwner && currentOwner.address !== scope.sessionName) { return `refused:${currentOwner.address}`; } diff --git a/src/daemon/__tests__/session-store-lifetime.test.ts b/src/daemon/__tests__/session-store-lifetime.test.ts index 80ca833207..edc9227369 100644 --- a/src/daemon/__tests__/session-store-lifetime.test.ts +++ b/src/daemon/__tests__/session-store-lifetime.test.ts @@ -23,8 +23,7 @@ test('refs capture records while resolving rebuilds from the same lifetime', () const initial = store.publish(ADDRESS, session); const lookup = store.lookup(ADDRESS)!; const listed = store.listRefs()[0]!; - const byDevice = store.findByDevice(session.device.id)!; - for (const ref of [lookup, listed, byDevice]) { + for (const ref of [lookup, listed]) { assert.notEqual(ref, initial); assert.equal(ref.lifetime, initial.lifetime); assert.equal(ref.session, session); @@ -33,7 +32,7 @@ test('refs capture records while resolving rebuilds from the same lifetime', () const rebuilt = store.update(initial, { appName: 'Reopened' }); assert.equal(initial.session, session); assert.equal(initial.session.appName, undefined); - for (const ref of [initial, lookup, listed, byDevice]) { + for (const ref of [initial, lookup, listed]) { assert.equal(store.resolveCurrent(ref), rebuilt); } assert.equal(store.lookup(ADDRESS)?.session, rebuilt); diff --git a/src/daemon/daemon-request.ts b/src/daemon/daemon-request.ts index bd9063528f..38af1369a9 100644 --- a/src/daemon/daemon-request.ts +++ b/src/daemon/daemon-request.ts @@ -9,6 +9,7 @@ import type { } from '@agent-device/kernel/contracts'; import type { DaemonWireRequest } from '@agent-device/contracts/command'; import type { RequestDispatchLedger } from './request-dispatch-ledger.ts'; +import type { DeviceClaimApp } from './device/device-claim-record.ts'; /** * The daemon's own request and response vocabulary: the wire shape from `@agent-device/contracts/command` (`DaemonWireRequest`) @@ -29,9 +30,16 @@ type DaemonRequestInternal = ReplayDispatchOptions & { * What an `open` spent from its `--wait` budget while it waited for this device outside the * device execution lock. The refusal the open ends with has to say the budget was spent, and * only the daemon that spent it can know that; `internal` never crosses the transport, so no - * client can claim a wait it did not perform. + * client can claim a wait it did not perform. The wait creates the object and updates it in + * place, so every copy of `internal` that lease admission makes reads the same spend. */ openDeviceWait?: { waitedMs: number }; + /** + * Present on an `open` with a `--wait` budget. The open calls it under the device lock when a + * session holds a claim that conflicts with the one it would take (`undefined`: the whole + * device), then returns its refusal; the wait decides whether to drop that refusal and wait. + */ + reportOpenDeviceConflict?: (openerApp: DeviceClaimApp | undefined) => void; publicNetworkOnly?: true; /** * The steps a batch still has ahead of this one. The open seam derives platform readiness diff --git a/src/daemon/device/__tests__/device-claim-record.test.ts b/src/daemon/device/__tests__/device-claim-record.test.ts index 00117b6db5..f4a6884a49 100644 --- a/src/daemon/device/__tests__/device-claim-record.test.ts +++ b/src/daemon/device/__tests__/device-claim-record.test.ts @@ -126,3 +126,18 @@ test('a record of an unknown schema version is unreadable', () => { expect(decodeStoredDeviceClaim(null)).toBeNull(); expect(decodeStoredDeviceClaim([allocatorClaim()])).toBeNull(); }); + +test('an app claim decodes only at the key of its own app', () => { + const app = { bundleId: 'com.example.one' }; + const appKey = `${DEVICE_KEY}:app:com.example.one`; + const record = decodeStoredDeviceClaim(currentClaim({ deviceKey: appKey, app })); + if (!record || isAllocatorHeldDeviceClaim(record)) throw new Error('expected process-owned'); + expect(record.app).toEqual(app); + + expect(decodeStoredDeviceClaim(currentClaim({ app }))).toBeNull(); + expect(decodeStoredDeviceClaim(currentClaim({ deviceKey: appKey }))).toBeNull(); + expect( + decodeStoredDeviceClaim(currentClaim({ deviceKey: `${DEVICE_KEY}:app:com.example.two`, app })), + ).toBeNull(); + expect(decodeStoredDeviceClaim(currentClaim({ deviceKey: appKey, app: {} }))).toBeNull(); +}); diff --git a/src/daemon/device/__tests__/device-claims-app.test.ts b/src/daemon/device/__tests__/device-claims-app.test.ts new file mode 100644 index 0000000000..b0a76437f0 --- /dev/null +++ b/src/daemon/device/__tests__/device-claims-app.test.ts @@ -0,0 +1,270 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { afterEach, test, vi } from 'vitest'; +import { + abandonDeviceClaim, + acquireDeviceClaim as acquireProductionDeviceClaim, + acquireTransientDeviceClaim, + releaseProvenStaleDeviceClaims, +} from '../device-claims.ts'; +import { + appScopedDeviceKey, + canonicalLocalDeviceKey, + resolveDeviceClaimPath, +} from '../device-claim-paths.ts'; +import { inspectDeviceClaims } from '../device-claim-inspection.ts'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; + +vi.mock('@agent-device/host-kit/process', async (importOriginal) => + (await import('../../../__tests__/test-utils/host-process-mock.ts')).pinOwnProcessStartTime( + importOriginal, + ), +); + +const roots: string[] = []; + +function acquireDeviceClaim( + params: Omit< + Parameters[0], + 'reconcileOrphanedDeviceClaim' + > & { + reconcileOrphanedDeviceClaim?: Parameters< + typeof acquireProductionDeviceClaim + >[0]['reconcileOrphanedDeviceClaim']; + }, +) { + return acquireProductionDeviceClaim({ + ...params, + reconcileOrphanedDeviceClaim: + params.reconcileOrphanedDeviceClaim ?? + (async () => ({ status: 'retained', reason: 'test-no-recovery' })), + }); +} + +afterEach(() => { + vi.restoreAllMocks(); + delete process.env.AGENT_DEVICE_CLAIMS_DIR; + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +function useClaimsRoot(): string { + const root = mkdtempForTestSync('agent-device-claims-'); + roots.push(root); + process.env.AGENT_DEVICE_CLAIMS_DIR = root; + return root; +} + +const mac: DeviceInfo = { + platform: 'apple', + appleOs: 'macos', + id: 'host-macos-local', + name: 'Host Mac', + kind: 'device', + target: 'desktop', + booted: true, +}; + +/** Claims `mac` (or one of its apps) for another daemon whose process is alive or `ownerPid`. */ +async function seedForeignMacClaim( + root: string, + options: { bundleId?: string; ownerPid?: number } = {}, +): Promise { + const stateDir = path.join(root, `foreign-${options.bundleId ?? 'mac'}`); + fs.mkdirSync(stateDir, { recursive: true }); + const seeded = await acquireDeviceClaim({ + device: mac, + session: 'foreign', + workspace: '/foreign', + stateDir, + ...(options.bundleId ? { app: { bundleId: options.bundleId } } : {}), + }); + assert.equal(seeded.status, 'acquired'); + if (seeded.status !== 'acquired') throw new Error('seed failed'); + const file = resolveDeviceClaimPath(seeded.ownership.deviceKey); + const stored = JSON.parse(fs.readFileSync(file, 'utf8')) as Record; + fs.writeFileSync( + file, + JSON.stringify({ + ...stored, + ownerPid: options.ownerPid ?? process.ppid, + ownerStartTime: options.ownerPid ? 'long-gone' : null, + }), + ); + return file; +} + +test('app claims of different apps coexist and the same app stays exclusive', async () => { + const root = useClaimsRoot(); + await seedForeignMacClaim(root, { bundleId: 'com.example.one' }); + + const other = await acquireDeviceClaim({ + device: mac, + session: 'two', + workspace: '/two', + stateDir: root, + app: { bundleId: 'com.example.two' }, + }); + assert.equal(other.status, 'acquired'); + if (other.status !== 'acquired') return; + assert.equal( + other.ownership.deviceKey, + appScopedDeviceKey(canonicalLocalDeviceKey(mac), 'com.example.two'), + ); + assert.deepEqual( + inspectDeviceClaims({ udid: mac.id }) + .map((entry) => entry.claim?.app?.bundleId) + .sort(), + ['com.example.one', 'com.example.two'], + ); + + const same = await acquireDeviceClaim({ + device: mac, + session: 'three', + workspace: '/three', + stateDir: root, + app: { bundleId: 'com.Example.One' }, + }); + assert.equal(same.status, 'conflict'); +}); + +test('a whole-Mac claim and a foreign app claim exclude each other in both orders', async () => { + const appFirst = useClaimsRoot(); + await seedForeignMacClaim(appFirst, { bundleId: 'com.example.one' }); + const whole = await acquireDeviceClaim({ + device: mac, + session: 'whole', + workspace: '/whole', + stateDir: appFirst, + }); + assert.equal(whole.status, 'conflict'); + if (whole.status !== 'conflict') return; + assert.equal(whole.conflict.claim?.app?.bundleId, 'com.example.one'); + const transient = await acquireTransientDeviceClaim({ + device: mac, + command: 'install', + workspace: '/whole', + stateDir: appFirst, + reconcileOrphanedDeviceClaim: async () => ({ status: 'reconciled' }), + }); + assert.equal(transient.status, 'conflict'); + + const wholeFirst = useClaimsRoot(); + await seedForeignMacClaim(wholeFirst); + const app = await acquireDeviceClaim({ + device: mac, + session: 'app', + workspace: '/app', + stateDir: wholeFirst, + app: { bundleId: 'com.example.one' }, + }); + assert.equal(app.status, 'conflict'); + if (app.status !== 'conflict') return; + assert.equal(app.conflict.claim?.app, undefined); +}); + +test("a daemon's own app claims do not fence its own whole-Mac claim", async () => { + const root = useClaimsRoot(); + const app = await acquireDeviceClaim({ + device: mac, + session: 'app', + workspace: '/app', + stateDir: root, + app: { bundleId: 'com.example.one' }, + }); + assert.equal(app.status, 'acquired'); + const whole = await acquireDeviceClaim({ + device: mac, + session: 'whole', + workspace: '/whole', + stateDir: root, + }); + assert.equal(whole.status, 'acquired'); +}); + +test('a whole-Mac claim settles a dead owner app claim and keeps one with unsettled resources', async () => { + const root = useClaimsRoot(); + const retainedFile = await seedForeignMacClaim(root, { + bundleId: 'com.example.one', + ownerPid: 999_999_999, + }); + const retained = await acquireDeviceClaim({ + device: mac, + session: 'whole', + workspace: '/whole', + stateDir: root, + }); + assert.equal(retained.status, 'conflict'); + assert.equal(fs.existsSync(retainedFile), true); + + const reconciled: string[] = []; + const settled = await acquireDeviceClaim({ + device: mac, + session: 'whole', + workspace: '/whole', + stateDir: root, + reconcileOrphanedDeviceClaim: async (claim) => { + reconciled.push(claim.deviceKey); + return { status: 'reconciled' }; + }, + }); + assert.equal(settled.status, 'acquired'); + assert.deepEqual(reconciled, [ + appScopedDeviceKey(canonicalLocalDeviceKey(mac), 'com.example.one'), + ]); + assert.equal(fs.existsSync(retainedFile), false); +}); + +test('an app claim of a dead owner is released by release --stale like any claim', async () => { + const root = useClaimsRoot(); + const file = await seedForeignMacClaim(root, { + bundleId: 'com.example.one', + ownerPid: 999_999_999, + }); + const outcomes = await releaseProvenStaleDeviceClaims({ + selectors: {}, + reconcile: async () => ({ status: 'reconciled' }), + }); + assert.deepEqual( + outcomes.map((outcome) => outcome.status), + ['released'], + ); + assert.equal(fs.existsSync(file), false); +}); + +test("an app claim taken over this daemon's abandoned whole-Mac claim leaves other daemons' apps free", async () => { + const root = useClaimsRoot(); + const whole = await acquireDeviceClaim({ + device: mac, + session: 'desktop', + workspace: '/desktop', + stateDir: root, + }); + assert.equal(whole.status, 'acquired'); + if (whole.status !== 'acquired') return; + assert.equal(await abandonDeviceClaim(whole.ownership), 'abandoned'); + + const app = await acquireDeviceClaim({ + device: mac, + session: 'app', + workspace: '/app', + stateDir: root, + app: { bundleId: 'com.example.one' }, + }); + assert.equal(app.status, 'acquired'); + if (app.status !== 'acquired') return; + assert.deepEqual(app.ownership.app, { bundleId: 'com.example.one' }); + assert.equal(fs.existsSync(resolveDeviceClaimPath(canonicalLocalDeviceKey(mac))), false); + + const foreignStateDir = path.join(root, 'foreign'); + fs.mkdirSync(foreignStateDir); + const foreign = await acquireDeviceClaim({ + device: mac, + session: 'foreign', + workspace: '/foreign', + stateDir: foreignStateDir, + app: { bundleId: 'com.example.two' }, + }); + assert.equal(foreign.status, 'acquired'); +}); diff --git a/src/daemon/device/device-claim-paths.ts b/src/daemon/device/device-claim-paths.ts index 47e8235a26..0ffd261f45 100644 --- a/src/daemon/device/device-claim-paths.ts +++ b/src/daemon/device/device-claim-paths.ts @@ -24,6 +24,15 @@ function formatLocalDeviceKey( return `local:${platform}:${appleOs ?? 'none'}:${id}`; } +/** + * The claim key of one app on a device: a per-app claim excludes other sessions of that app only, + * while the device key itself excludes every app (ADR 0034). LaunchServices matches bundle ids + * without regard to case, so the key does too. + */ +export function appScopedDeviceKey(deviceKey: string, bundleId: string): string { + return `${deviceKey}:app:${bundleId.toLowerCase()}`; +} + export function resolveDeviceClaimRoot(): string { const override = process.env.AGENT_DEVICE_CLAIMS_DIR?.trim(); return override diff --git a/src/daemon/device/device-claim-record.ts b/src/daemon/device/device-claim-record.ts index a6eda252ec..01486b622f 100644 --- a/src/daemon/device/device-claim-record.ts +++ b/src/daemon/device/device-claim-record.ts @@ -10,7 +10,7 @@ import { type RuntimeOwnerRef, } from '@agent-device/contracts/platform-runtime'; -import { canonicalLocalDeviceKey } from './device-claim-paths.ts'; +import { appScopedDeviceKey, canonicalLocalDeviceKey } from './device-claim-paths.ts'; /** Schema version of a process-owned claim record; v1 records migrate to it on read. */ export const DEVICE_CLAIM_SCHEMA_VERSION = 2; @@ -30,6 +30,8 @@ export type DeviceClaim = { schemaVersion: 2; deviceKey: string; device: DeviceIdentity & { name: string }; + /** Present when the claim holds one app of the device rather than the whole device. */ + app?: DeviceClaimApp; session: string; workspace: string; stateDir: string; @@ -42,6 +44,8 @@ export type DeviceClaim = { abandonedAtMs?: number; }; +export type DeviceClaimApp = { bundleId: string }; + /** * The ownership token a claim grants its holder: everything a clearing surface must match to let * that holder release, abandon, or keep fencing the claim, and nothing else. @@ -51,6 +55,8 @@ export type DeviceClaimSessionOwnership = { ownerToken: string; ownerPid: number; ownerStartTime: string | null; + /** The one app the claim holds; absent when it holds the whole device. */ + app?: DeviceClaimApp; }; /** The ownership token carried by a persisted claim record. */ @@ -60,6 +66,7 @@ export function ownershipFromClaim(claim: DeviceClaim): DeviceClaimSessionOwners ownerToken: claim.ownerToken, ownerPid: claim.ownerPid, ownerStartTime: claim.ownerStartTime, + ...(claim.app ? { app: claim.app } : {}), }; } @@ -223,17 +230,29 @@ function buildDecodedClaim( const location = decodeClaimLocation(raw); const owner = decodeClaimOwner(raw); const timestamps = decodeClaimTimestamps(raw); - if (!location || !owner || !timestamps) return null; - if (location.deviceKey !== canonicalLocalDeviceKey(identity)) return null; + const app = decodeClaimApp(raw.app); + if (!location || !owner || !timestamps || app === null) return null; + const deviceKey = canonicalLocalDeviceKey(identity); + const expectedKey = app ? appScopedDeviceKey(deviceKey, app.bundleId) : deviceKey; + if (location.deviceKey !== expectedKey) return null; return { schemaVersion: DEVICE_CLAIM_SCHEMA_VERSION, ...location, device: { ...identity, name }, + ...(app ? { app } : {}), ...owner, ...timestamps, }; } +/** `undefined` for a whole-device claim, `null` for an `app` field that does not decode. */ +function decodeClaimApp(value: unknown): DeviceClaimApp | undefined | null { + if (value === undefined) return undefined; + if (!isRecord(value)) return null; + const bundleId = readNonEmptyString(value.bundleId); + return bundleId ? { bundleId } : null; +} + function decodeClaimLocation( raw: Record, ): Pick | null { diff --git a/src/daemon/device/device-claims.ts b/src/daemon/device/device-claims.ts index 3a1b399436..50e5be65ba 100644 --- a/src/daemon/device/device-claims.ts +++ b/src/daemon/device/device-claims.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import { deviceIdentity, isApplePlatform, + isMacOs, resolveDeviceAppleOs, type DeviceIdentity, type DeviceInfo, @@ -22,6 +23,7 @@ import { } from './device-claim-inspection.ts'; import type { TakenOverDeviceClaim } from './device-claim-reboot.ts'; import { + appScopedDeviceKey, canonicalLocalDeviceKey, resolveDeviceClaimPath, resolveDeviceClaimRoot, @@ -31,6 +33,7 @@ import { ownershipFromClaim, type AllocatorClaimIdentity, type DeviceClaim, + type DeviceClaimApp, type DeviceClaimSessionOwnership, } from './device-claim-record.ts'; import { @@ -83,13 +86,50 @@ export async function acquireDeviceClaim(params: { * that outlives a live owner. Callers that have no answer to give omit it and keep the conflict. */ observeDeviceBoot?: DeviceBootObservationService; + /** Claims only this app of the device; other apps stay available to other sessions. */ + app?: DeviceClaimApp; }): Promise { const identity = deviceClaimIdentity(params.device); const deviceKey = canonicalLocalDeviceKey(identity); - return await withDeviceClaimLock( - deviceKey, - async () => await claimHeldDevice({ ...params, deviceKey, identity }), - ); + const { app } = params; + // Lock order is device key, then app key: every acquisition on the device holds the device + // key's lock, so a whole-device claim and an app claim are never written past each other. + return await withDeviceClaimLock(deviceKey, async () => { + if (!app) return await claimHeldDevice({ ...params, deviceKey, identity }); + const deviceClaim = await resolveExistingClaim({ + ...params, + deviceKey, + owner: readCurrentOwnerIdentity(), + }); + if (deviceClaim.status === 'conflict') return deviceClaim; + if (deviceClaim.status === 'held') { + const held = inspectDeviceClaimFile(resolveDeviceClaimPath(deviceKey)); + if (held) return { status: 'conflict', conflict: held }; + } + const appKey = appScopedDeviceKey(deviceKey, app.bundleId); + const acquired = await withDeviceClaimLock( + appKey, + async () => await claimHeldDevice({ ...params, deviceKey: appKey, identity, app }), + ); + if (acquired.status !== 'acquired') return acquired; + removeSupersededDeviceClaim(deviceKey); + const tookOver = + acquired.tookOver ?? (deviceClaim.status === 'available' ? deviceClaim.tookOver : undefined); + return tookOver ? { ...acquired, tookOver } : acquired; + }); +} + +/** + * An app acquisition that found the device key available leaves no record there: what remains can + * only be this daemon's own abandoned whole-device claim, which would otherwise keep fencing every + * other daemon's apps. The caller holds the device key's lock. + */ +function removeSupersededDeviceClaim(deviceKey: string): void { + try { + fs.unlinkSync(resolveDeviceClaimPath(deviceKey)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } } /** @@ -141,9 +181,14 @@ async function claimHeldDevice(params: { stateDir: string; reconcileOrphanedDeviceClaim: DeviceClaimReconciler; observeDeviceBoot?: DeviceBootObservationService; + app?: DeviceClaimApp; }): Promise { const { deviceKey, identity } = params; const owner = readCurrentOwnerIdentity(); + if (!params.app && isMacOs(params.device)) { + const appConflict = await settleForeignAppClaims(deviceKey, params); + if (appConflict) return { status: 'conflict', conflict: appConflict }; + } const existing = await resolveExistingClaim({ ...params, owner, @@ -158,6 +203,7 @@ async function claimHeldDevice(params: { ...identity, name: params.device.name, }, + ...(params.app ? { app: params.app } : {}), session: params.session, workspace: params.workspace, stateDir: params.stateDir, @@ -175,6 +221,40 @@ async function claimHeldDevice(params: { }; } +/** + * A whole-device claim excludes every app claim on the device held by another daemon. Runs under the + * device key's lock, which every app claim acquisition also holds, so no app claim appears behind + * the scan. An app claim whose owner provably cannot release it is settled like any stale claim; + * any other foreign app claim is the conflict. This daemon's own app claims are its session + * store's business, and a transient command is covered by them. + */ +async function settleForeignAppClaims( + deviceKey: string, + params: { stateDir: string; reconcileOrphanedDeviceClaim: DeviceClaimReconciler }, +): Promise { + const appKeyPrefix = appScopedDeviceKey(deviceKey, ''); + const owner = readCurrentOwnerIdentity(); + for (const entry of inspectDeviceClaims({})) { + const scanned = entry.claim; + if (!scanned?.app || !scanned.deviceKey.startsWith(appKeyPrefix)) continue; + if (isClaimOwnedByThisDaemon(scanned, params.stateDir, owner)) continue; + const conflict = await withDeviceClaimLock(scanned.deviceKey, async () => { + const current = inspectDeviceClaimFile(resolveDeviceClaimPath(scanned.deviceKey)); + if (!current) return undefined; + if (!current.claim) return current; + if (isClaimOwnedByThisDaemon(current.claim, params.stateDir, owner)) return undefined; + if (!deviceClaimOwnerCannotRelease(current.classification)) return current; + const settled = await settleVerifiedOrphanedClaim( + current.claim, + params.reconcileOrphanedDeviceClaim, + ); + return settled.status === 'retained' ? current : undefined; + }); + if (conflict) return conflict; + } + return undefined; +} + /** * Does this process hold the claim for exactly this device right now? The * Apple runner's device-claim arbitration probe (#1320 retained-runner rule): diff --git a/src/daemon/handlers/__tests__/session-device-claims.test.ts b/src/daemon/handlers/__tests__/session-device-claims.test.ts index 193af847a0..29c46d4412 100644 --- a/src/daemon/handlers/__tests__/session-device-claims.test.ts +++ b/src/daemon/handlers/__tests__/session-device-claims.test.ts @@ -565,7 +565,13 @@ test('an open under a macos-app lease holds only its app, beside other claims on name: 'other-app', device: MACOS_DEVICE, appBundleId: 'com.example.other', - lease: { leaseId: 'b'.repeat(32), tenantId: 't2', runId: 'r2', leaseBackend: 'macos-app' }, + lease: { + leaseId: 'b'.repeat(32), + tenantId: 't2', + runId: 'r2', + leaseBackend: 'macos-app', + deviceKey: 'com.example.other', + }, createdAt: Date.now(), actions: [], }); diff --git a/src/daemon/open-device-contention-wait.ts b/src/daemon/open-device-contention-wait.ts index 58dfc8679f..0cc33eb02a 100644 --- a/src/daemon/open-device-contention-wait.ts +++ b/src/daemon/open-device-contention-wait.ts @@ -5,6 +5,10 @@ import { Deadline, sleep } from '@agent-device/host-kit/retry'; import { AppError } from '@agent-device/kernel/errors'; import type { DaemonRequest } from './daemon-request.ts'; import type { SessionStore } from './session-store.ts'; +import type { SessionRef, SessionState } from './session-state.ts'; +import type { DeviceClaimApp } from './device/device-claim-record.ts'; +import { parseMacOsAppLeaseKey } from './macos-app-lease.ts'; +import type { DeviceLease } from '@agent-device/contracts/device'; import type { SessionRecoveryOptions } from './session-recovery-hints.ts'; /** @@ -48,7 +52,7 @@ export function readOpenWaitBudgetMs(req: DaemonRequest): number | undefined { */ export function readOpenWaitAttempt(req: DaemonRequest): SessionRecoveryOptions { const waitedMs = req.internal?.openDeviceWait?.waitedMs; - return waitedMs === undefined ? {} : { waitedMs }; + return waitedMs ? { waitedMs } : {}; } /** @@ -87,6 +91,10 @@ export type OpenDeviceWait = { * lock this request holds. So either nobody holds the device for as long as `task` runs, or * this open gives the device back and spends the rest of its budget waiting again — which is * what lets several callers queue on one device instead of all but the fastest refusing early. + * + * Which sessions conflict depends on the claim the open would take, which it resolves only under + * the locks. When it reports a conflict through `reportOpenDeviceConflict` the first time, or + * while budget is left, the refusal `task` returns is dropped and the open waits again. */ runWhenDeviceIsUnheld(params: { acquireLocks: (task: () => Promise) => Promise; @@ -117,16 +125,28 @@ export function beginOpenDeviceWait(params: { return createOpenDeviceWait({ req, sessionName, sessionStore, deviceId, budgetMs }); } -/** The other session holding `deviceId`, or `undefined` when nothing stands between this open and - * the device — including when this open's own session is what holds it. */ -function findSessionHoldingDevice( - sessionStore: SessionStore, - deviceId: string, - sessionName: string, -): ReturnType { - const inUse = sessionStore.findByDevice(deviceId); - if (!inUse || inUse.address === sessionName) return undefined; - return inUse; +/** + * The other session standing between this open and `deviceId`, or `undefined` when there is none, + * including when this open's own session is what holds the device. Until the open has resolved the + * claim it would take, only a session holding the whole device is certain to conflict with it. + */ +function findSessionHoldingDevice(params: { + sessionStore: SessionStore; + deviceId: string; + sessionName: string; + opener: { app: DeviceClaimApp | undefined } | undefined; +}): SessionRef | undefined { + const { sessionStore, deviceId, sessionName, opener } = params; + return sessionStore + .listRefs() + .find( + (ref) => + ref.session.device.id === deviceId && + ref.address !== sessionName && + (opener + ? sessionConflictsWithOpen(ref.session, opener.app) + : sessionHeldApp(ref.session) === undefined), + ); } type LockedAttempt = { ran: true; outcome: Outcome } | { ran: false }; @@ -140,7 +160,16 @@ function createOpenDeviceWait(params: { }): OpenDeviceWait { const { req, sessionName, sessionStore, deviceId, budgetMs } = params; const deadline = Deadline.fromTimeoutMs(budgetMs); - const holder = () => findSessionHoldingDevice(sessionStore, deviceId, sessionName); + let opener: { app: DeviceClaimApp | undefined } | undefined; + let openDeferred = false; + const holder = () => findSessionHoldingDevice({ sessionStore, deviceId, sessionName, opener }); + const internal = (req.internal ??= {}); + const spend = (internal.openDeviceWait ??= { waitedMs: 0 }); + internal.reportOpenDeviceConflict = (openerApp) => { + const firstLook = opener === undefined; + opener = { app: openerApp }; + openDeferred = firstLook || !deadline.isExpired(); + }; const waitForDeviceOutsideLocks = async (): Promise => { for (;;) { @@ -176,9 +205,11 @@ function createOpenDeviceWait(params: { if (!deadline.isExpired()) return { ran: false }; // The refusal this open is about to get can say the budget was spent, because under // these locks it is: nobody else can hand the device over any more. - recordOpenWaitSpend(req, deadline.elapsedMs()); + spend.waitedMs = Math.max(0, deadline.elapsedMs()); } - return { ran: true, outcome: await task() }; + openDeferred = false; + const outcome = await task(); + return openDeferred ? { ran: false } : { ran: true, outcome }; }); if (attempt.ran) return attempt.outcome; // The device was taken after the look that let this open reach the locks. Leaving them @@ -189,8 +220,44 @@ function createOpenDeviceWait(params: { }; } -function recordOpenWaitSpend(req: DaemonRequest, waitedMs: number): void { - if (waitedMs <= 0) return; - const internal = (req.internal ??= {}); - internal.openDeviceWait = { waitedMs }; +/** + * The one app a session drives on its device, or `undefined` when it holds the whole device. An + * app claim (ADR 0034) and a `macos-app` lease (ADR 0007, which takes no claim) each scope a + * session to one app. + */ +export function sessionHeldApp( + session: Pick, +): DeviceClaimApp | undefined { + if (session.lease?.leaseBackend === 'macos-app') return leasedApp(session.lease.deviceKey); + return session.deviceClaim?.app; +} + +/** The app a request admitted under a `macos-app` lease drives. */ +export function admittedLeaseApp( + lease: Pick | undefined, +): DeviceClaimApp | undefined { + return lease?.backend === 'macos-app' ? leasedApp(lease.deviceKey) : undefined; +} + +/** + * Whether a session on the device stands in the way of an open that would hold `openerApp`, or the + * whole device when `openerApp` is `undefined`. Two sessions on one device conflict unless each + * holds one app and the apps differ. The new-session conflict check and the `open --wait` that + * queues on it both decide with this. + */ +export function sessionConflictsWithOpen( + session: Pick, + openerApp: DeviceClaimApp | undefined, +): boolean { + const heldApp = sessionHeldApp(session); + return !heldApp || !openerApp || isSameApp(heldApp, openerApp); +} + +/** LaunchServices matches bundle ids without regard to case. */ +export function isSameApp(a: DeviceClaimApp, b: DeviceClaimApp): boolean { + return a.bundleId.toLowerCase() === b.bundleId.toLowerCase(); +} + +function leasedApp(deviceKey: string | undefined): DeviceClaimApp { + return { bundleId: parseMacOsAppLeaseKey(deviceKey).bundleId }; } diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts index 8d05648d5d..a9beb1f210 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts @@ -74,6 +74,7 @@ import { clearRuntimeHintValues, } from '../../../../platform-runtime-runtime-hints.ts'; import { resolveAndroidPackageForOpen } from '@agent-device/platform-android/mechanics'; +import { resolveIosApp } from '@agent-device/platform-apple/app-resolution'; import { dispatchApplicationLifecycleEffect } from '../../../__tests__/application-lifecycle-runtime-fixture.ts'; import { makeAndroidEmulator, @@ -82,6 +83,7 @@ import { noopInvoke, } from './session-open-runtime.fixtures.ts'; import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; +import { beginOpenDeviceWait, readOpenWaitBudgetMs } from '../../../open-device-contention-wait.ts'; const mockDispatch = vi.mocked(dispatchApplicationLifecycleEffect); const mockApplyRuntimeHints = vi.mocked(applyRuntimeHintValues); @@ -650,3 +652,153 @@ for (const transition of ['rebuild', 'retire'] as const) { } }); } + +const HOST_MAC = { + platform: 'apple', + appleOs: 'macos', + id: 'host-macos-local', + name: 'Host Mac', + kind: 'device', + target: 'desktop', + booted: true, +} as const; + +function nativeMacOpener(sessionStore: ReturnType) { + vi.stubEnv('AGENT_DEVICE_MACOS_APP_BACKEND', 'native'); + vi.mocked(resolveIosApp).mockImplementation(async (_device, app) => app); + mockResolveTargetDevice.mockResolvedValue(HOST_MAC); + return async (session: string, positionals: string[], extra: Partial = {}) => + await handleSessionCommands({ + req: { + token: 't', + session, + command: 'open', + positionals, + flags: { platform: 'macos' }, + ...extra, + }, + sessionName: session, + logPath: path.join(mkdtempForTestSync('agent-device-macos-app-claim-'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); +} + +function restoreMacOpener(): void { + vi.unstubAllEnvs(); + vi.mocked(resolveIosApp).mockImplementation(async () => 'com.example.demo'); +} + +function lastOpenWasBackground(): boolean { + const context = mockDispatch.mock.calls.at(-1)?.[4] as { background?: boolean } | undefined; + return context?.background === true; +} + +test('native macOS app sessions claim their app, so another app opens beside them', async () => { + const sessionStore = makeSessionStore(); + const open = nativeMacOpener(sessionStore); + const refusedFor = { ok: false, error: { details: { reason: 'app-claim-scope' } } }; + + expect(await open('one', ['com.example.one'])).toMatchObject({ ok: true }); + expect(lastOpenWasBackground()).toBe(true); + expect(await open('two', ['com.example.two'])).toMatchObject({ ok: true }); + expect(await open('three', ['com.example.one'])).toMatchObject({ + ok: false, + error: { code: 'DEVICE_IN_USE' }, + }); + expect(await open('one', ['com.example.two'])).toMatchObject(refusedFor); + expect(await open('one', ['demo://route'])).toMatchObject(refusedFor); + expect(await open('one', ['com.example.one'])).toMatchObject({ ok: true }); + expect(lastOpenWasBackground()).toBe(true); + + restoreMacOpener(); +}); + +test('a macos-app lease session and an app session of the same bundle conflict in both orders', async () => { + const lease = (bundleId: string) => ({ + leaseId: `lease-${bundleId}`, + tenantId: 'tenant', + runId: 'run', + backend: 'macos-app' as const, + deviceKey: bundleId, + createdAt: Date.now(), + heartbeatAt: Date.now(), + expiresAt: Date.now() + 60_000, + }); + const leasedOpen = (bundleId: string): Partial => ({ + internal: { admittedLease: lease(bundleId) }, + }); + const inUse = { ok: false, error: { code: 'DEVICE_IN_USE' } }; + + const leaseFirst = makeSessionStore(); + const openBesideLease = nativeMacOpener(leaseFirst); + expect( + await openBesideLease('leased', ['com.example.one'], leasedOpen('com.example.one')), + ).toMatchObject({ ok: true }); + expect(lastOpenWasBackground()).toBe(true); + expect(await openBesideLease('app', ['com.example.one'])).toMatchObject(inUse); + expect(await openBesideLease('other', ['com.example.two'])).toMatchObject({ ok: true }); + + const appFirst = makeSessionStore(); + const openBesideApp = nativeMacOpener(appFirst); + expect(await openBesideApp('app', ['com.example.one'])).toMatchObject({ ok: true }); + expect( + await openBesideApp('leased', ['com.example.one'], leasedOpen('com.example.one')), + ).toMatchObject(inUse); + expect( + await openBesideApp('leased-two', ['com.example.two'], leasedOpen('com.example.two')), + ).toMatchObject({ ok: true }); + + restoreMacOpener(); +}); + +test('a macOS app session that holds the whole Mac opens its app in front', async () => { + const sessionStore = makeSessionStore(); + const open = nativeMacOpener(sessionStore); + vi.stubEnv('AGENT_DEVICE_MACOS_APP_BACKEND', 'xctest'); + + expect(await open('xctest', ['com.example.one'])).toMatchObject({ ok: true }); + expect(sessionStore.get('xctest')?.deviceClaim?.app).toBeUndefined(); + expect(lastOpenWasBackground()).toBe(false); + + restoreMacOpener(); +}); + +test('a whole-Mac open with a wait budget waits beside an app session and opens once it retires', async () => { + const sessionStore = makeSessionStore(); + const open = nativeMacOpener(sessionStore); + expect(await open('one', ['com.example.one'])).toMatchObject({ ok: true }); + const dispatchesBeforeWait = mockDispatch.mock.calls.length; + + const req: DaemonRequest = { + token: 't', + session: 'whole', + command: 'open', + positionals: [], + flags: { platform: 'macos', waitMs: 5_000 }, + meta: { requestId: 'req-whole-wait' }, + }; + const wait = beginOpenDeviceWait({ + req, + budgetMs: readOpenWaitBudgetMs(req), + sessionName: 'whole', + sessionStore, + deviceId: HOST_MAC.id, + })!; + const holder = sessionStore.lookup('one')!; + setTimeout(() => sessionStore.retire(holder), 300); + + const startedAtMs = Date.now(); + const response = await wait.runWhenDeviceIsUnheld({ + acquireLocks: async (task) => await task(), + task: async () => + await open('whole', [], { flags: req.flags, internal: req.internal, meta: req.meta }), + }); + + expect(response).toMatchObject({ ok: true }); + expect(Date.now() - startedAtMs).toBeGreaterThanOrEqual(250); + expect(mockDispatch.mock.calls.length).toBe(dispatchesBeforeWait + 1); + expect(sessionStore.get('whole')?.deviceClaim?.app).toBeUndefined(); + + restoreMacOpener(); +}); diff --git a/src/daemon/session-lifecycle/internal/session-open-execution.ts b/src/daemon/session-lifecycle/internal/session-open-execution.ts index 5764438cf0..4aa7555079 100644 --- a/src/daemon/session-lifecycle/internal/session-open-execution.ts +++ b/src/daemon/session-lifecycle/internal/session-open-execution.ts @@ -9,7 +9,7 @@ import { } from '@agent-device/device-selection/device-selection-resolver'; import type { BoundDeviceRuntime } from '@agent-device/contracts/platform-runtime'; import type { SessionSurface } from '@agent-device/contracts/session'; -import type { DeviceInfo } from '@agent-device/kernel/device'; +import { isMacOs, type DeviceInfo } from '@agent-device/kernel/device'; import type { DaemonRequest, DaemonResponse } from '../../daemon-request.ts'; import type { SessionRef, SessionState } from '../../session-state.ts'; import { @@ -42,7 +42,6 @@ import { buildDeviceInUseBySessionError, buildForeignWorkspaceSessionConflict, } from '../../session-recovery-hints.ts'; -import { describeOpenWaitForRefusal } from '../../open-device-contention-wait.ts'; import { isImplicitSessionScopeConflict, resolvePublicSessionName } from '../../session-routing.ts'; import { applicationLifecycleExecutionFromRequest } from '../../application-lifecycle-execution.ts'; import { @@ -55,6 +54,17 @@ import { type DeviceClaimReconciler, } from '../../device/device-claims.ts'; import type { TakenOverDeviceClaim } from '../../device/device-claim-reboot.ts'; +import type { DeviceClaimApp } from '../../device/device-claim-record.ts'; +import { + admittedLeaseApp, + describeOpenWaitForRefusal, + isSameApp, + sessionConflictsWithOpen, + sessionHeldApp, +} from '../../open-device-contention-wait.ts'; +import { isDeepLinkTarget } from '@agent-device/contracts/command'; +import { readMacOsAppBackend } from '@agent-device/contracts/session'; +import { readHostEnvironmentVariable } from '@agent-device/host-kit/process'; import { deviceBootObservation } from '../../../platform-runtime-device-boot.ts'; import { appendResponseWarning } from './session-open-warnings.ts'; import { @@ -240,6 +250,7 @@ export async function completeOpenCommand(params: { runtimeLaunchUrl: runtimeHints?.launchUrl, appBundleId: sessionAppBundleId, surface, + ...(opensAppInBackground(req, existingSession, deviceClaim) ? { background: true } : {}), hasExistingSession: existingSession !== undefined, relaunch: shouldRelaunch, prewarmRunnerBeforeOpen: req.flags?.maestro?.prewarmRunnerBeforeOpen === true, @@ -373,33 +384,40 @@ async function prepareOpenDispatchSession(params: { } /** - * A session admitted under a `macos-app` lease holds one app, not the host Mac (ADR 0007): other - * app-leased sessions on the same Mac do not stand in its way. + * A session scoped to one app opens it without bringing it to the front, which leaves the + * frontmost app and every other session's app where they are (ADR 0034). */ -function isMacOsAppLeaseOpen(req: DaemonRequest): boolean { - return req.internal?.admittedLease?.backend === 'macos-app'; +function opensAppInBackground( + req: DaemonRequest, + existingSession: SessionState | undefined, + deviceClaim: DeviceClaimSessionOwnership | undefined, +): boolean { + const heldApp = existingSession + ? sessionHeldApp(existingSession) + : (deviceClaim?.app ?? admittedLeaseApp(req.internal?.admittedLease)); + return heldApp !== undefined; } /** - * The refusal an open gets when another session already holds the device. The wait an expired - * `--wait` budget spent is carried into the recovery text, because a caller that waited is not - * helped by being told to wait. + * The refusal an open gets when another session already holds a conflicting part of the device. + * The wait an expired `--wait` budget spent is carried into the recovery text, because a caller + * that waited is not helped by being told to wait. */ function findNewSessionDeviceConflict(params: { req: DaemonRequest; device: DeviceInfo; sessionStore: SessionStore; + openerApp: DeviceClaimApp | undefined; }): DaemonFailureResponse | undefined { - const { req, device, sessionStore } = params; - const inUse = isMacOsAppLeaseOpen(req) - ? sessionStore - .listRefs() - .find( - (ref) => - ref.session.device.id === device.id && ref.session.lease?.leaseBackend !== 'macos-app', - ) - : sessionStore.findByDevice(device.id); + const { req, device, sessionStore, openerApp } = params; + const inUse = sessionStore + .listRefs() + .find( + (ref) => + ref.session.device.id === device.id && sessionConflictsWithOpen(ref.session, openerApp), + ); if (!inUse) return undefined; + req.internal?.reportOpenDeviceConflict?.(openerApp); // The wait the caller paid for belongs to `open` alone: an interaction that hits the same busy // device cannot wait for it, and would be sent off with a flag its own command rejects. const attempt = describeOpenWaitForRefusal(req); @@ -409,6 +427,82 @@ function findNewSessionDeviceConflict(params: { return buildDeviceInUseBySessionError(inUse, device, attempt); } +/** + * The app a native macOS app session claims instead of the whole Mac (ADR 0034). The native + * backend acts on the session app alone, so another session may drive another app. Every other + * macOS session, an open whose app is not known before launch, and a `macos-app` lease (which + * takes no claim) get no app claim. + */ +async function resolveAppClaim(params: { + req: DaemonRequest; + device: DeviceInfo; + surface: SessionSurface; + openTarget: string | undefined; + lifecycle: OpenApplicationRuntime; +}): Promise { + const { req, device, surface, openTarget, lifecycle } = params; + if ( + !isMacOs(device) || + surface !== 'app' || + !openTarget || + isDeepLinkTarget(openTarget) || + deviceClaimRuleForOwner(lifecycle.owner, req.internal?.admittedLease) !== 'ordinary' || + readMacOsAppBackend(readHostEnvironmentVariable) !== 'native' + ) { + return undefined; + } + const { appBundleId } = await lifecycle.operations.resolveOpenTarget({ + target: openTarget, + surface, + foreground: false, + }); + return appBundleId ? { bundleId: appBundleId } : undefined; +} + +/** + * A session that holds an app claim may reopen only that app on the app surface: anything else + * would drive what its claim does not cover. + */ +export function reopenOutsideAppClaim(params: { + session: SessionState; + surface: SessionSurface; + appBundleId: string | undefined; +}): DaemonResponse | undefined { + const { session, surface, appBundleId } = params; + const heldApp = session.deviceClaim?.app; + if (!heldApp) return undefined; + if ( + surface === 'app' && + appBundleId !== undefined && + isSameApp(heldApp, { bundleId: appBundleId }) + ) { + return undefined; + } + return errorResponse( + 'INVALID_ARGS', + `Session "${session.name}" holds ${heldApp.bundleId} only, so it can reopen only that app on the app surface.`, + { + reason: 'app-claim-scope', + deviceKey: session.deviceClaim?.deviceKey, + hint: `To open a link in this app, name the app: open ${heldApp.bundleId} . For another app or surface, close this session or use a new --session.`, + }, + ); +} + +/** The claim was taken for the app resolved before preparation; the open must launch that app. */ +function openedOutsideAppClaim( + app: DeviceClaimApp | undefined, + resolved: string | undefined, + openTarget: string | undefined, +): DaemonResponse | undefined { + if (!app || (resolved !== undefined && isSameApp(app, { bundleId: resolved }))) return undefined; + return errorResponse( + 'COMMAND_FAILED', + `${openTarget ?? 'The app'} resolved to ${resolved ?? 'no app'} after its claim was taken for ${app.bundleId}.`, + { reason: 'app-claim-scope', hint: 'Run the same open again.' }, + ); +} + async function acquireDeviceClaimForOwner(params: { req: DaemonRequest; device: DeviceInfo; @@ -416,12 +510,14 @@ async function acquireDeviceClaimForOwner(params: { sessionName: string; sessionStore: SessionStore; reconcileOrphanedDeviceClaim: DeviceClaimReconciler; + app: DeviceClaimApp | undefined; }): Promise< | DeviceClaimAcquireResult | { status: 'not-required' } | { status: 'refused'; response: DaemonResponse } > { - const { req, device, owner, sessionName, sessionStore, reconcileOrphanedDeviceClaim } = params; + const { req, device, owner, sessionName, sessionStore, reconcileOrphanedDeviceClaim, app } = + params; switch (deviceClaimRuleForOwner(owner, req.internal?.admittedLease)) { case 'none': return { status: 'not-required' }; @@ -449,10 +545,48 @@ async function acquireDeviceClaimForOwner(params: { stateDir: sessionStore.resolveDaemonStateDir(), reconcileOrphanedDeviceClaim, observeDeviceBoot: deviceBootObservation, + ...(app ? { app } : {}), }); } } +/** Takes the claim a new session needs: its app's, or the whole device's. */ +async function claimNewSessionDevice(params: { + req: DaemonRequest; + device: DeviceInfo; + surface: SessionSurface; + openTarget: string | undefined; + lifecycle: OpenApplicationRuntime; + sessionName: string; + sessionStore: SessionStore; + reconcileOrphanedDeviceClaim: DeviceClaimReconciler; +}): Promise< + | { response: DaemonResponse } + | { + app: DeviceClaimApp | undefined; + deviceClaim: DeviceClaimSessionOwnership | undefined; + tookOverDeviceClaim: TakenOverDeviceClaim | undefined; + } +> { + const { req, device, lifecycle, sessionStore } = params; + const app = await resolveAppClaim(params); + const conflict = findNewSessionDeviceConflict({ + req, + device, + sessionStore, + openerApp: admittedLeaseApp(req.internal?.admittedLease) ?? app, + }); + if (conflict) return { response: conflict }; + const ownerClaim = await acquireDeviceClaimForOwner({ ...params, owner: lifecycle.owner, app }); + if (ownerClaim.status === 'conflict') { + return { response: buildDeviceClaimConflictError(device, ownerClaim.conflict) }; + } + if (ownerClaim.status === 'refused') return { response: ownerClaim.response }; + return ownerClaim.status === 'acquired' + ? { app, deviceClaim: ownerClaim.ownership, tookOverDeviceClaim: ownerClaim.tookOver } + : { app, deviceClaim: undefined, tookOverDeviceClaim: undefined }; +} + export async function openNewSessionWithDeviceClaim(params: { req: DaemonRequest; sessionName: string; @@ -484,25 +618,18 @@ export async function openNewSessionWithDeviceClaim(params: { selection, } = params; requireOpenSessionAdmission(sessionStore, sessionName, undefined); - const conflict = findNewSessionDeviceConflict({ req, device, sessionStore }); - if (conflict) return { type: 'response', response: conflict }; - - const ownerClaim = await acquireDeviceClaimForOwner({ + const claimed = await claimNewSessionDevice({ req, device, - owner: lifecycle.owner, + surface, + openTarget, + lifecycle, sessionName, sessionStore, reconcileOrphanedDeviceClaim, }); - if (ownerClaim.status === 'conflict') - return { - type: 'response', - response: buildDeviceClaimConflictError(device, ownerClaim.conflict), - }; - if (ownerClaim.status === 'refused') return { type: 'response', response: ownerClaim.response }; - const deviceClaim = ownerClaim.status === 'acquired' ? ownerClaim.ownership : undefined; - const tookOverDeviceClaim = ownerClaim.status === 'acquired' ? ownerClaim.tookOver : undefined; + if ('response' in claimed) return { type: 'response', response: claimed.response }; + const { app, deviceClaim, tookOverDeviceClaim } = claimed; const effects: NewSessionOpenEffects = { mayHaveStarted: false }; const rollbackClaim = async () => await rollbackNewSessionClaim({ @@ -527,6 +654,11 @@ export async function openNewSessionWithDeviceClaim(params: { await rollbackClaim(); return { type: 'response', response: details.response }; } + const outsideClaim = openedOutsideAppClaim(app, details.details.appBundleId, openTarget); + if (outsideClaim) { + await rollbackClaim(); + return { type: 'response', response: outsideClaim }; + } // Preparation can boot the device or warm caches, but it cannot establish session ownership. // Stamping here is what covers a boot preparation caused for this very open; from // `completeOpenCommand` on, a relaunch-close or a runtime-hint write may already have touched the diff --git a/src/daemon/session-lifecycle/internal/session-open.ts b/src/daemon/session-lifecycle/internal/session-open.ts index 8cc765fa3c..38faa7e4bc 100644 --- a/src/daemon/session-lifecycle/internal/session-open.ts +++ b/src/daemon/session-lifecycle/internal/session-open.ts @@ -39,6 +39,7 @@ import { completeOpenCommand, openNewSessionWithDeviceClaim, renewOpenSessionClaim, + reopenOutsideAppClaim, type OpenApplicationRuntime, type SessionOpenResult, type RuntimeHintApplyOperation, @@ -243,6 +244,12 @@ async function handleOpenCommand(params: SessionOpenCommandInput): Promise this.captureRef(address, entry)); diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 8561afd7ee..d27408ff30 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -348,6 +348,7 @@ agent-device snapshot -i --platform apple --target desktop - Use `click --button secondary` for context menus on macOS, then run `snapshot -i` again. - On `frontmost-app` and `menubar` surfaces, `press` and `click` post synthetic mouse events through the macOS helper (the `desktop` surface inspects only): `--hold-ms` is how long the button stays down (at least 40 ms, 60 ms by default, because AppKit drops a release posted in the same tick as its press), `--count` is that many independent clicks (apps that detect a double-click by timing, such as Finder, still read two clicks at the default interval as one; pass an `--interval-ms` longer than the system double-click time to keep them apart), and `--double-tap` posts each click as a double-click pair. A long schedule such as `--hold-ms 10000 --count 4` is given the time it needs, and a helper stopped mid-hold — by a cancelled request, a dropped client, or its deadline — releases the button before it exits. `--jitter-px` is not applied on these surfaces. - With `AGENT_DEVICE_MACOS_APP_BACKEND=native`, macOS app sessions run without XCTest: no Automation Mode overlay, the app can stay behind other windows, and the real pointer stays with you while a drawn pointer shows each action. `click`, `press`, and `fill` use accessibility actions on the element at their target point, which must lie inside one of the app's own on-screen windows and not on its menu bar; `type` inserts at the focused control and falls back to key events sent to the app; `scroll` moves the accessibility scroll bar under the front window's center. Chromium-based apps expose their full tree. The daemon then never starts the Apple runner on macOS: `record`, `prepare`, `back`, `longpress` and `--hold-ms`, `--double-tap`, `--button secondary`, and gestures are refused with `UNSUPPORTED_OPERATION` (`reason: unsupported-device-backend`), as is a click on an element with no accessibility action. Use the default XCTest backend for those. `screenshot` captures only the app's front window, even when other windows cover it. +- With the native backend, an app session claims only its app, not the whole Mac, and `open ` launches or reopens that app without bringing it to the front: sessions in other daemons (separate `AGENT_DEVICE_STATE_DIR`s) or other sessions of the same daemon can drive other apps at the same time, while a second session on the same app, including a `macos-app` lease session, gets `DEVICE_IN_USE`, or waits for it under `open --wait`. Desktop, `menubar` and `frontmost-app` sessions, XCTest sessions, and opens of a URL alone still claim the whole Mac, which excludes every app claim. An app session cannot reopen another app or surface; close it or use another `--session`. Two copies of one app need distinct bundle ids. Screenshots from all sessions on the Mac are taken one at a time. - Mobile-only helpers are unsupported on macOS: `boot`, `shutdown`, `home`, `orientation`, `app-switcher`, `action-button`, `fold`, `install`, `reinstall`, `install-from-source`, and `push`. Recommended loops: