diff --git a/.changeset/neutral-macos-application-ownership.md b/.changeset/neutral-macos-application-ownership.md new file mode 100644 index 0000000000..c0e3bf2da0 --- /dev/null +++ b/.changeset/neutral-macos-application-ownership.md @@ -0,0 +1,35 @@ +--- +'@fluentui-react-native/desktop-driver': patch +'@fluentui-react-native/desktop-test': minor +'@fluentui-react-native/storybook-desktop': patch +--- + +Add opt-in neutral macOS supervisor-owned application launch, exact kernel +incarnation attach and cleanup, and cross-helper application exclusion. Record +pending and acquired applications as workload claim resources, including +uncontained launches and uncertain failures. + +Workload claim schema 3 uses the driver's OS process identity instead of an +uptime-derived wall-clock estimate. Legacy or unverified identities remain +unknown during inspection. `claimDesktopWorkload` now returns a promise and +must be awaited; the Storybook integration awaits it before starting. + +Pre-spawn and rejected LaunchServices acquisitions release their claims. +Dispatched or surviving acquisitions remain fenced, and non-contention lock +failures clean up only the exact application that the helper launched. + +Read-only macOS application identity inspection shares descriptor verification +without acquiring a controlling lease or disturbing session window records. +Storybook bridge-epoch recovery uses it on the embedded helper while real +launch/attach operations retain cross-service exact-incarnation exclusion. + +Discover live macOS application candidates from kernel PIDs rather than the +main-run-loop-dependent workspace cache, so read-only inspection sees newly +launched apps and still refuses duplicate identities. + +Native ownership contracts additionally qualify failed and interrupted +acquisition publication, abrupt owner death and known uncontained descendants: +uncertainty retains the workload claim until exact operator cleanup. This adds +qualification only, not automatic survivor adoption or descendant containment. +Abandoned qualification owners now retire nonzero after two minutes, and the +AppKit fixture requests termination after five minutes; workload fences remain. diff --git a/packages/agentic/desktop-driver/native/PROTOCOL.md b/packages/agentic/desktop-driver/native/PROTOCOL.md index eb19273e4f..b3521e364f 100644 --- a/packages/agentic/desktop-driver/native/PROTOCOL.md +++ b/packages/agentic/desktop-driver/native/PROTOCOL.md @@ -125,6 +125,60 @@ and `external` for an attached application it never ends. A provider that omits `containment` makes no containment claim. The field is additive and does not change the wire version. +The macOS provider emits its kernel `proc_pidinfo` start timestamp with exactly +six fractional digits and `Z`, never a LaunchServices launch-date estimate. Its +launched lease additionally reports `launchKind: "launch-services" | +"direct-child"` and `containment: "uncontained"`; an attached lease reports +`containment: "external"`. Causal LaunchServices ownership is not parent/child +containment. Cleanup rechecks the captured exact incarnation before each signal; +it never adopts whichever process currently occupies a launch PID. +Initial capture also checks the causal child or LaunchServices application +identity before and after the kernel read. LaunchServices substitution and +interactive authorization prompts are disabled. + +macOS attach descriptors may supply `processIdentity: { processId, +processStartedAt }` with the exact native timestamp and an exact `windowTitle`. +Optional bundle/executable constraints must also match. This external identity +cannot be combined with `leasePath`/`leaseNonce`, cannot authorize launch, and +never grants termination authority. Existing Storybook lease files remain a +separate, legacy identity path. Independent helpers hold a stable per-user +exclusive lock for each exact application incarnation until its lease is +released. Lock contention never permits signalling another helper's application. +The macOS-only `inspectApplication` command accepts the same configured target +parameters as `attach` and returns only `{ processId, processStartedAt }`. It +performs the same descriptor, legacy-lease/nonce, bundle/executable, exact-window +and uniqueness checks, with the exact kernel incarnation captured before window +discovery and rechecked afterwards. It does not create an application lease, +acquire an application or physical-input lock, register windows/elements, or +grant control/termination authority. Identity observation can therefore run on +the session's helper while its controlling lease remains held, without stealing +window records or permitting another service to attach. Cancellation and +unverifiable identity/Accessibility fail closed. Other providers do not implement +this command. +Other lock failures after a proven launch use exact-incarnation cleanup and +report `exited` only when that primary process is confirmed gone. +Explicit app paths are authoritative; bundle-only launch rejects +multiple distinct registered existing bundle paths rather than selecting one. + +A failed or cancelled macOS launch reports +`applicationSettlement: "not-started" | "exited" | "unknown"` in +`response.error.data` or `cancelled.data`. `not-started` proves rejection before +process creation or a completed LaunchServices failure with no application. +`exited` requires exact cleanup proof; an unreadable or surviving incarnation is +`unknown`. A process that was created is named by its captured `processId`, +`processStartedAt` when readable, and `launchKind`. A pending launch whose +LaunchServices callback has not arrived may have no process identity; callers +must retain their pending acquisition rather than infer no effect. Node also +marks a dispatched launch `unknown` if its helper transport fails before a +response. A legacy provider's ordinary no-data rejection is not evidence of a +created process. These failures may additionally include bounded +`launchErrorDomain`, `launchErrorCode`, +`underlyingErrorDomain`, and `underlyingErrorCode` diagnostics, without arbitrary +NSError user information. A callback returning both an error and a new process +uses the same exact-incarnation cleanup path. These +additive fields are described in [protocol.json](protocol.json) and retain +wire version 1.0. + Element snapshots can additionally include `required` as a supported Boolean or an unsupported value with its reason. Older or non-supporting providers may omit it; the Node boundary treats absence as unsupported. This is additive @@ -526,6 +580,9 @@ The helper stops side effects, unwinds command-local work, and emits: `effect` is the mutation effect described above and is present for mutating commands. A queued request that is cancelled before it starts reports `none`. +An optional `data` object preserves lifecycle settlement evidence, including +the exact unleased application from a cancelled macOS launch. It is not a +mutation-effect verdict and never authorizes a retry. Node retains the session queue and physical-input ownership until the request settles. Failure to settle before the cancellation deadline is fatal to the @@ -597,11 +654,13 @@ privacy access. `--service-identity` follows the one-shot contract below; see [the macOS service verifier](../references/security.md#macos-service-verifier). `--application-incarnation` also follows it: the request is exactly `{"pid":,"startedAt":""}`, and the -answer is `{"ok":true,"state":"running"|"exited"|"unknown"}`. `running` means -the process id names a process whose start time is within 10 ms of `startedAt`; -`exited` means the process id is free or a zombie, or names a process that -started more than one second later; anything else, including a process the -kernel will not show this user, is `unknown`. See +answer is `{"ok":true,"state":"running"|"exited"|"unknown"}`. For canonical +macOS six-fractional-digit UTC identities, `running` requires exact equality; +`exited` means the PID is gone/a zombie or names a strictly later kernel +incarnation, including a one-microsecond difference. An earlier timestamp or +unreadable identity is `unknown`. Legacy timestamps keep the old compatibility +policy: `running` within 10 ms, `exited` only when gone or more than one second +later. Legacy precision must not be used to authorize exact owned cleanup. See [settlement journal and recovery](../references/security.md#settlement-journal-and-recovery). The Windows provider additionally implements: diff --git a/packages/agentic/desktop-driver/native/macos/README.md b/packages/agentic/desktop-driver/native/macos/README.md index e95aa487c9..ef6f3be47c 100644 --- a/packages/agentic/desktop-driver/native/macos/README.md +++ b/packages/agentic/desktop-driver/native/macos/README.md @@ -24,6 +24,7 @@ Accessibility. | `Application.swift` | Launch/attach leases, exact process/window identity, and ownership | | `ApplicationTermination.swift` | `terminateOwned`: quit, wait, kill, and verify the exit of an owned process | | `ApplicationIncarnation.swift` | One-shot `--application-incarnation`: whether a recorded process exited | +| `ApplicationLock.swift` | Stable per-user exclusion for an exact application PID/kernel start time | | `Accessibility.swift` | AX tree walking, lookup, snapshots, focus, hit testing, actions, and source | | `Observation.swift` | Provider-neutral bounded sampler: request rules, budgets, and BFS result | | `AccessibilityObservation.swift` | AX source for `observe`: multi-attribute reads, shared ids, `observe` run | @@ -84,6 +85,38 @@ The coordinator: SwiftPM `.build`, DerivedData, editor files, and other generated output never participate in source identity or staging. +### Application ownership fixture + +The native ownership contracts use a small AppKit application, independent of +Storybook and React Native. From this package, build it into an ignored +artifact directory: + +```sh +node native/macos/fixtures/build.cjs artifacts/desktop/application-fixture +``` + +The command prints the verified bundle/executable identity and window title. +The arm64 fixture pins its compiler and bundle minimum macOS version to 11.0, +matching the helper instead of inheriting a newer machine deployment target. +The executable accepts `--title ` and an optional +`--ready-file ` containing its PID after window creation; its Increment button +and counter have accessibility identifiers `ownership-fixture-increment` and +`ownership-fixture-count`. Use the normal native host launch API, or launch the +exact executable externally for attach-preservation tests. The test owner must +stop only that process incarnation. The fixture is supporting native evidence, +not Fabric/Paper renderer qualification. Its main run loop requests termination +after five minutes if the test owner never cleans it up. + +For crash-recovery contracts, `--descendant-ready-file ` starts a bounded +`/bin/sleep 300` child and writes its PID and parent PID as JSON. The fixture does +not terminate that child when its primary process exits. The contract owner +verifies both native start identities and the parent relationship, proves the +descendant survived, and separately stops that exact descendant. This tests the +limits of `uncontained` ownership; it does not add descendant containment. +The crash-owner Node fixture waits at most two minutes before exiting nonzero; +the helper then observes EOF. These fixture-only retirement bounds do not +settle or remove an abandoned workload claim. + ## Signing ### Stable development signing @@ -170,8 +203,11 @@ transport: the helper writes a fixed diagnostic and exits with status 1 instead The one-shot `--application-incarnation` mode reads exactly `{ "pid", "startedAt" }` from stdin and answers whether that exact process, a process id and its start time, has `exited`, is `running`, or cannot be told (`unknown`). -`exited` is reported only with proof: the process id is gone (a zombie included) or now names a process that -started more than a second later. Settlement recovery uses it to decide whether a launched application that the helper +New leases use kernel start timestamps with exactly six fractional digits. +For these identities, `running` requires equality and `exited` requires a gone/zombie PID +or a strictly later kernel start time; unreadable/earlier identities are unknown. +Legacy lower-precision records retain their historical one-second exit tolerance. +Settlement recovery uses it to decide whether a launched application that the helper could not prove closed is really gone; [PROTOCOL.md](../PROTOCOL.md) defines its request and answer. The input lock lives beneath @@ -179,6 +215,12 @@ The input lock lives beneath helpers started through different Aqua bootstrap namespaces still serialize physical input. Restricted `--release-input` accepts only the Node-owned ledger. `--sweep` is an operator recovery diagnostic, not normal cleanup. +Exact application leases hold a separate owner-only `flock` beneath that root's +`applications` directory. It excludes other helpers for the same PID and +microsecond start identity, independent of checkouts or temporary directories. +Attached leases only release the lock; they never terminate the app. Launched +leases distinguish `launch-services` from `direct-child` causal ownership and +report `uncontained`: neither promises descendant containment. The one-shot `--service-identity` mode describes a service process or verifies a service claim for `describeMacOSService` and `createMacOSServiceVerifier`; it uses only public process and socket information APIs and links `libbsm` for the @@ -192,12 +234,12 @@ resident memory for the life of the session (about 25 KB for a `probe`, 80 KB for a `tree`, and 250 KB for a 500-element `observe`). A new thread that serves requests must drain a pool per request too. -The main run loop is also what refreshes `NSWorkspace.runningApplications` and `NSRunningApplication.isTerminated`. -Measured without it, a launched application registers within about 0.2 s, but after the process was killed -`isTerminated` stayed false and the workspace list kept the dead process for the 15 s the probe watched; with the -run loop pumped both updated within 0.25 s. Registration therefore needs no run loop, while exit never trusts that -state: termination verification and lease checks read the kernel through `ApplicationIncarnation`, and attaching -without a lease drops listed processes the kernel no longer knows. +The main run loop refreshes `NSWorkspace.runningApplications` and `NSRunningApplication.isTerminated`. +Without it, the workspace's cached array can omit a newly launched application and retain an exited one. +Descriptor-based discovery therefore enumerates the current user's kernel PIDs +and constructs `NSRunningApplication` instances directly instead of trusting that cached array. +Enumeration failure refuses discovery; exact window and uniqueness checks still apply. +Termination verification and lease checks independently read the kernel through `ApplicationIncarnation`. ## Application and window identity diff --git a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Application.swift b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Application.swift index 7672d887d7..d89b829c4c 100644 --- a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Application.swift +++ b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Application.swift @@ -12,6 +12,9 @@ final class ApplicationLeaseRecord { let storyRootTestId: String? let processID: pid_t let processStartedAt: Date + let exactStartedAt: String + let launchKind: String? + let exclusion: ExactApplicationLock let bundleIdentifier: String? let executablePath: String? let windowTitle: String @@ -23,6 +26,9 @@ final class ApplicationLeaseRecord { storyRootTestId: String?, processID: pid_t, processStartedAt: Date, + exactStartedAt: String, + launchKind: String?, + exclusion: ExactApplicationLock, bundleIdentifier: String?, executablePath: String?, windowTitle: String @@ -33,18 +39,24 @@ final class ApplicationLeaseRecord { self.storyRootTestId = storyRootTestId self.processID = processID self.processStartedAt = processStartedAt + self.exactStartedAt = exactStartedAt + self.launchKind = launchKind + self.exclusion = exclusion self.bundleIdentifier = bundleIdentifier self.executablePath = executablePath self.windowTitle = windowTitle } func json() -> JSONObject { - [ + var result: JSONObject = [ "id": id, "ownership": ownership, "processId": Int(processID), - "processStartedAt": formatDate(processStartedAt), + "processStartedAt": exactStartedAt, + "containment": ownership == "launched" ? "uncontained" : "external", ] + if let launchKind { result["launchKind"] = launchKind } + return result } } @@ -89,6 +101,7 @@ private final class LaunchResultBox: @unchecked Sendable { var application: NSRunningApplication? var error: Error? var abandoned = false + var startedAt: String? } struct AXWindowAttributeDiagnostic { @@ -306,7 +319,31 @@ final class ApplicationManager { let commands = CommandJournal() func launch(_ params: JSONObject, token: CancellationToken) throws -> ApplicationLeaseRecord { + var dispatched = false + do { + return try acquireLaunchedApplication(params, token: token, didDispatch: { dispatched = true }) + } catch { + var data = (error as? HelperError)?.data ?? (error as? CancelledError)?.data ?? [:] + if data["applicationSettlement"] == nil { + data["applicationSettlement"] = dispatched ? "unknown" : "not-started" + } + if let cancellation = error as? CancelledError { + throw CancelledError(effect: cancellation.effect, data: data) + } + if let failure = error as? HelperError { + throw HelperError(code: failure.code, message: failure.message, data: data, effect: failure.effect) + } + throw HelperError(code: ErrorCode.launchFailed, message: error.localizedDescription, data: data) + } + } + + private func acquireLaunchedApplication( + _ params: JSONObject, token: CancellationToken, didDispatch: () -> Void + ) throws -> ApplicationLeaseRecord { let application = try requireObject(params["application"], "The launch request is missing its application descriptor.") + if application["processIdentity"] != nil { + try fail(ErrorCode.invalidParams, "An exact external application identity cannot authorize a launch.") + } var launchTimeout = 90_000 if let value = params["launchTimeoutMs"] { guard let number = value as? NSNumber, !isBoolean(number), @@ -333,11 +370,12 @@ final class ApplicationManager { let arguments = try parseArguments(application["arguments"]) let preexisting = Set( NSWorkspace.shared.runningApplications - .filter { bundleIdentifier == nil || $0.bundleIdentifier == bundleIdentifier } .map(\.processIdentifier) ) let running: NSRunningApplication + let startedAt: String + let launchKind: String if let appURL = try resolveApplicationURL(bundleIdentifier: bundleIdentifier, executablePath: executablePath) { let bundle = Bundle(url: appURL) if let bundleIdentifier, bundle?.bundleIdentifier != bundleIdentifier { @@ -346,9 +384,13 @@ final class ApplicationManager { "The selected application bundle does not have the expected bundle identifier \"\(bundleIdentifier)\"." ) } - running = try launchApplication( + didDispatch() + let launched = try launchApplication( at: appURL, arguments: arguments, preexisting: preexisting, timeoutMilliseconds: launchTimeout, token: token ) + running = launched.application + startedAt = launched.startedAt + launchKind = "launch-services" if preexisting.contains(running.processIdentifier) { try fail( ErrorCode.launchFailed, @@ -368,18 +410,30 @@ final class ApplicationManager { } catch { try fail(ErrorCode.launchFailed, "Starting the application failed: \(error.localizedDescription)") } + didDispatch() let processID = process.processIdentifier + guard let identity = ApplicationIncarnation.captureCausalIdentity( + matchesProcess: { process.isRunning }, + inspect: { try? ServiceIdentity.inspect(processID).startedAt } + ) else { + throw abandonedLaunch(processID: processID, startedAt: nil, launchKind: "direct-child", + error: HelperError(code: ErrorCode.launchFailed, message: "The launched child identity could not be read.")) + } + startedAt = identity + launchKind = "direct-child" let registered: NSRunningApplication? do { registered = try awaitRunningApplication( processID: processID, timeoutMilliseconds: launchTimeout, token: token ) } catch { - throw abandonedLaunch(processID: processID, error: error) + throw abandonedLaunch(processID: processID, startedAt: startedAt, launchKind: launchKind, error: error) } guard let application = registered else { throw abandonedLaunch( processID: processID, + startedAt: startedAt, + launchKind: launchKind, error: HelperError( code: ErrorCode.launchFailed, message: "The launched process did not become a macOS running application." @@ -391,7 +445,15 @@ final class ApplicationManager { try fail(ErrorCode.launchFailed, "The application could not be resolved for launch.") } + let exclusion = try ExactApplicationLock.acquireLaunched( + processID: running.processIdentifier, startedAt: startedAt, launchKind: launchKind + ) do { + guard ApplicationIncarnation.classifyExact( + recorded: startedAt, observed: ApplicationIncarnation.observeExact(running.processIdentifier) + ) == .running else { + try fail(ErrorCode.launchFailed, "The launched application incarnation changed before it became ready.") + } try validateApplication( running, expectedBundleIdentifier: bundleIdentifier, @@ -409,26 +471,16 @@ final class ApplicationManager { ownership: "launched", running: running, windowTitle: windowTitle, - params: params + params: params, + expectedStartedAt: startedAt, + launchKind: launchKind, + exclusion: exclusion ) } catch { - throw abandonedLaunch(processID: running.processIdentifier, error: error) + throw abandonedLaunch(processID: running.processIdentifier, startedAt: startedAt, launchKind: launchKind, error: error) } } - /// Ends the process a failed or cancelled launch started and returns the error to report. A process that will not - /// exit has no lease to keep it visible, so the failure names it instead of hiding the survivor. - private func abandonedLaunch(processID: pid_t, error: Error) -> Error { - if terminateUnleased(processID: processID, bounds: .abandoned) || error is CancelledError { - return error - } - return HelperError( - code: ErrorCode.launchFailed, - message: "The launch failed and the application process it started did not exit.", - data: ["processId": Int(processID)] - ) - } - /// A bare executable becomes a `NSRunningApplication` only when it registers with Launch Services, which can be /// after the process starts, so the lookup polls until the launch timeout and gives up early if the process exits. private func awaitRunningApplication( @@ -449,7 +501,33 @@ final class ApplicationManager { } } + private struct Attachment { + let running: NSRunningApplication + let windowTitle: String + let startedAt: String + } + func attach(_ params: JSONObject, token: CancellationToken) throws -> ApplicationLeaseRecord { + let resolved = try resolveAttachment(params, token: token) + return try createLease( + ownership: "attached", running: resolved.running, windowTitle: resolved.windowTitle, params: params, + expectedStartedAt: resolved.startedAt + ) + } + + /// Identity observation must not acquire control or register windows belonging to an active session. + func inspectApplication(_ params: JSONObject, token: CancellationToken) throws -> JSONObject { + let resolved = try resolveAttachment(params, token: token) + try token.throwIfCancelled() + let pid = resolved.running.processIdentifier + guard ApplicationIncarnation.classifyExact(recorded: resolved.startedAt, observed: ApplicationIncarnation.observeExact(pid)) == .running + else { + try fail(ErrorCode.attachFailed, "The application incarnation changed or became unreadable during identity inspection.") + } + return ["processId": Int(pid), "processStartedAt": resolved.startedAt] + } + + private func resolveAttachment(_ params: JSONObject, token: CancellationToken) throws -> Attachment { let application = try requireObject(params["application"], "The attach request is missing its application descriptor.") try failIfSessionUnavailable("Attaching to the application") let endpoint = optionalString(params, "endpoint") ?? "macos" @@ -457,6 +535,31 @@ final class ApplicationManager { let configuredExecutablePath = optionalString(application, "executablePath") let configuredWindowTitle = optionalString(application, "windowTitle") + if let rawIdentity = application["processIdentity"] { + guard application["leasePath"] == nil, application["leaseNonce"] == nil else { + try fail(ErrorCode.leaseInvalid, "Exact application identity cannot be combined with a legacy lease.") + } + let identity = try requireObject(rawIdentity, "The exact application identity must be an object.") + guard Set(identity.keys) == ["processId", "processStartedAt"], + let pid = try? ServiceIdentity.requiredPid(identity, "processId"), + let expected = optionalString(identity, "processStartedAt"), + ServiceIdentity.isTimestamp(expected), + let windowTitle = configuredWindowTitle + else { + try fail(ErrorCode.leaseInvalid, "Exact attach requires a PID, native microsecond start time, and window title.") + } + guard ApplicationIncarnation.classifyExact(recorded: expected, observed: ApplicationIncarnation.observeExact(pid)) == .running, + let running = NSRunningApplication(processIdentifier: pid) + else { + try fail(ErrorCode.leaseInvalid, "The exact external application incarnation is not running or cannot be verified.") + } + try validateApplication(running, expectedBundleIdentifier: configuredBundleIdentifier, + expectedExecutablePath: configuredExecutablePath, code: ErrorCode.leaseInvalid) + _ = try waitForUniqueWindow(processID: pid, exactTitle: windowTitle, timeoutMilliseconds: 10_000, + token: token, code: ErrorCode.attachFailed) + return Attachment(running: running, windowTitle: windowTitle, startedAt: expected) + } + if let leasePath = optionalString(application, "leasePath") { let lease = try readLease(path: leasePath, token: token) guard integer(lease, "schemaVersion") == 1 else { @@ -484,7 +587,8 @@ final class ApplicationManager { try fail(ErrorCode.leaseInvalid, "The leased application process is no longer running.") } guard - let actualStart = processStartTime(pid), + let nativeIdentity = try? ServiceIdentity.inspect(pid), + let actualStart = parseDate(nativeIdentity.startedAt), let expectedStartText = optionalString(lease, "processStartedAt"), let expectedStart = parseDate(expectedStartText) else { @@ -537,7 +641,7 @@ final class ApplicationManager { token: token, code: ErrorCode.attachFailed ) - return try createLease(ownership: "attached", running: running, windowTitle: windowTitle, params: params) + return Attachment(running: running, windowTitle: windowTitle, startedAt: nativeIdentity.startedAt) } guard let windowTitle = configuredWindowTitle else { @@ -546,9 +650,7 @@ final class ApplicationManager { "Attaching without a lease requires an exact \"windowTitle\" in the application descriptor." ) } - // The workspace list and isTerminated are refreshed by the main run loop, which this helper never runs, so a - // process that already exited can stay listed. Confirm each candidate with the kernel. - let candidates = NSWorkspace.shared.runningApplications.filter { application in + let candidates = try runningApplications().filter { application in guard !application.isTerminated, processIsAlive(application.processIdentifier) else { return false } @@ -560,12 +662,13 @@ final class ApplicationManager { } return true } - var matches: [(NSRunningApplication, AXUIElement)] = [] + var matches: [(NSRunningApplication, String?)] = [] var queryOutcomes: [AXWindowQueryOutcome] = [] var titleErrorCount = 0 var titleErrors: [JSONObject] = [] for candidate in candidates { try token.throwIfCancelled() + let startedAt = (try? ServiceIdentity.inspect(candidate.processIdentifier))?.startedAt let outcome = queryAccessibilityWindows(processID: candidate.processIdentifier) queryOutcomes.append(outcome) let reason = classifyAXWindowQuery( @@ -599,7 +702,7 @@ final class ApplicationManager { continue } if candidateTitle == windowTitle { - matches.append((candidate, window)) + matches.append((candidate, startedAt)) } } } @@ -699,7 +802,22 @@ final class ApplicationManager { expectedExecutablePath: configuredExecutablePath, code: ErrorCode.attachFailed ) - return try createLease(ownership: "attached", running: running, windowTitle: windowTitle, params: params) + guard let startedAt = matches[0].1 else { + try fail(ErrorCode.attachFailed, "The application process start identity could not be read.") + } + return Attachment(running: running, windowTitle: windowTitle, startedAt: startedAt) + } + + private func runningApplications() throws -> [NSRunningApplication] { + let processIDs: [pid_t] + do { + processIDs = try ServiceIdentity.ownProcesses() + } catch { + try fail(ErrorCode.automationFailed, "The current application processes cannot be enumerated.", + data: ["reason": "application-discovery-unavailable"]) + } + // NSWorkspace's cached array misses new applications without a main run loop. Resolve each live PID instead. + return processIDs.compactMap { NSRunningApplication(processIdentifier: $0) } } /// Releases a lease. A launched application is proven exited first, so a process that will not die keeps its lease @@ -715,7 +833,8 @@ final class ApplicationManager { processID: lease.processID, startedAt: lease.processStartedAt, bounds: .close, - token: token + token: token, + exactStartedAt: lease.exactStartedAt ) } removeLease(leaseID) @@ -725,9 +844,9 @@ final class ApplicationManager { /// True only when the leased process is provably the incarnation the lease recorded: the same process id with the same /// start time. A process the kernel will not describe is not proven, so callers fail closed. private func isRunning(_ lease: ApplicationLeaseRecord) -> Bool { - ApplicationIncarnation.classify( - recorded: lease.processStartedAt, - observed: ApplicationIncarnation.observe(lease.processID) + ApplicationIncarnation.classifyExact( + recorded: lease.exactStartedAt, + observed: ApplicationIncarnation.observeExact(lease.processID) ) == .running } @@ -910,9 +1029,15 @@ final class ApplicationManager { ownership: String, running: NSRunningApplication, windowTitle: String, - params: JSONObject + params: JSONObject, + expectedStartedAt: String? = nil, + launchKind: String? = nil, + exclusion: ExactApplicationLock? = nil ) throws -> ApplicationLeaseRecord { - guard let start = processStartTime(running.processIdentifier) else { + guard let identity = try? ServiceIdentity.inspect(running.processIdentifier), + let start = parseDate(identity.startedAt), + expectedStartedAt == nil || expectedStartedAt == identity.startedAt + else { try fail( ownership == "launched" ? ErrorCode.launchFailed : ErrorCode.attachFailed, "The application process start identity could not be read." @@ -924,6 +1049,9 @@ final class ApplicationManager { storyRootTestId: optionalString(params, "storyRootTestId"), processID: running.processIdentifier, processStartedAt: start, + exactStartedAt: identity.startedAt, + launchKind: launchKind, + exclusion: try exclusion ?? ExactApplicationLock.acquire(processID: running.processIdentifier, startedAt: identity.startedAt), bundleIdentifier: running.bundleIdentifier, executablePath: processExecutablePath(running.processIdentifier), windowTitle: windowTitle @@ -1073,13 +1201,6 @@ final class ApplicationManager { bundleIdentifier: String?, executablePath: String? ) throws -> URL? { - var byIdentifier: URL? - if let bundleIdentifier { - byIdentifier = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) - if byIdentifier == nil { - try fail(ErrorCode.launchFailed, "No installed application has bundle identifier \"\(bundleIdentifier)\".") - } - } var byExecutable: URL? if let executablePath { var candidate = URL(fileURLWithPath: executablePath).standardizedFileURL @@ -1091,12 +1212,16 @@ final class ApplicationManager { candidate.deleteLastPathComponent() } } - if let byIdentifier, let byExecutable { - guard canonicalPath(byIdentifier.path) == canonicalPath(byExecutable.path) else { - try fail(ErrorCode.launchFailed, "The configured bundle identifier and executable resolve to different applications.") - } + // An explicit built product is authoritative even when another checkout registered the same bundle identifier. + if let byExecutable { return byExecutable } + if executablePath != nil { return nil } + guard let bundleIdentifier else { return nil } + let candidates = Set(NSWorkspace.shared.urlsForApplications(withBundleIdentifier: bundleIdentifier) + .filter { FileManager.default.fileExists(atPath: $0.path) }.map { canonicalPath($0.path) }) + guard candidates.count == 1, let selected = candidates.first else { + try fail(ErrorCode.launchFailed, "The bundle identifier has no unique installed application; configure an exact executable path.") } - return byIdentifier ?? byExecutable + return URL(fileURLWithPath: selected) } private func launchApplication( @@ -1105,25 +1230,36 @@ final class ApplicationManager { preexisting: Set, timeoutMilliseconds: Int, token: CancellationToken - ) throws -> NSRunningApplication { + ) throws -> (application: NSRunningApplication, startedAt: String) { let configuration = NSWorkspace.OpenConfiguration() configuration.arguments = arguments configuration.activates = false configuration.addsToRecentItems = false configuration.createsNewApplicationInstance = true + configuration.allowsRunningApplicationSubstitution = false + configuration.promptsUserIfNeeded = false let result = LaunchResultBox() let semaphore = DispatchSemaphore(value: 0) NSWorkspace.shared.openApplication(at: url, configuration: configuration) { application, error in + let startedAt = application.flatMap { launched in + ApplicationIncarnation.captureCausalIdentity(matchesProcess: { + // AppKit equality distinguishes application incarnations even when a PID has been reused. + guard !launched.isTerminated, + let current = NSRunningApplication(processIdentifier: launched.processIdentifier) else { return false } + return current.isEqual(launched) + }, inspect: { try? ServiceIdentity.inspect(launched.processIdentifier).startedAt }) + } result.lock.lock() result.application = application result.error = error + result.startedAt = startedAt let abandoned = result.abandoned result.lock.unlock() if abandoned, let application, !preexisting.contains(application.processIdentifier) { // The request is over, so nothing waits for this: end the process on its own queue, with a hung app killed. let processID = application.processIdentifier DispatchQueue.global(qos: .utility).async { - terminateUnleased(processID: processID, bounds: .late) + terminateUnleased(processID: processID, startedAt: startedAt, bounds: .late) } } semaphore.signal() @@ -1140,23 +1276,51 @@ final class ApplicationManager { result.lock.lock() result.abandoned = true let application = result.application + let startedAt = result.startedAt result.lock.unlock() if let application, !preexisting.contains(application.processIdentifier) { - throw abandonedLaunch(processID: application.processIdentifier, error: error) + throw abandonedLaunch(processID: application.processIdentifier, startedAt: startedAt, launchKind: "launch-services", error: error) + } + var data = (error as? HelperError)?.data ?? (error as? CancelledError)?.data ?? [:] + data["launchKind"] = "launch-services" + data["applicationSettlement"] = "unknown" + if let cancellation = error as? CancelledError { + throw CancelledError(effect: cancellation.effect, data: data) + } + if let failure = error as? HelperError { + throw HelperError(code: failure.code, message: failure.message, data: data) } throw error } result.lock.lock() let application = result.application let error = result.error + let startedAt = result.startedAt result.lock.unlock() if let error { - try fail(ErrorCode.launchFailed, "Launch Services could not start the application: \(error.localizedDescription)") + let failure = error as NSError + var data: JSONObject = ["launchKind": "launch-services", "applicationSettlement": application == nil ? "not-started" : "unknown", + "launchErrorDomain": bounded(failure.domain, bytes: 160), "launchErrorCode": failure.code] + if let underlying = failure.userInfo[NSUnderlyingErrorKey] as? NSError { + data["underlyingErrorDomain"] = bounded(underlying.domain, bytes: 160) + data["underlyingErrorCode"] = underlying.code + } + let launchError = HelperError(code: ErrorCode.launchFailed, + message: "Launch Services could not start the application: \(error.localizedDescription)", data: data) + if let application, !preexisting.contains(application.processIdentifier) { + throw abandonedLaunch(processID: application.processIdentifier, startedAt: startedAt, launchKind: "launch-services", error: launchError) + } + throw launchError } guard let application else { - try fail(ErrorCode.launchFailed, "Launch Services did not return the launched application.") + try fail(ErrorCode.launchFailed, "Launch Services did not return the launched application.", + data: ["launchKind": "launch-services", "applicationSettlement": "unknown"]) + } + guard let startedAt else { + throw abandonedLaunch(processID: application.processIdentifier, startedAt: nil, launchKind: "launch-services", + error: HelperError(code: ErrorCode.launchFailed, message: "Launch Services did not provide a verifiable process incarnation.")) } - return application + return (application, startedAt) } private func validateApplication( diff --git a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/ApplicationIncarnation.swift b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/ApplicationIncarnation.swift index 84006f71e1..2a3c3dc143 100644 --- a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/ApplicationIncarnation.swift +++ b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/ApplicationIncarnation.swift @@ -23,6 +23,12 @@ enum ApplicationIncarnation { case unavailable } + enum ExactObservation { + case gone + case started(String) + case unavailable + } + static func run() -> Int32 { let answer = handle(ServiceIdentity.readRequest()) var line = (try? serializeJSON(answer)) ?? Data(#"{"ok":false,"reason":"request"}"#.utf8) @@ -48,7 +54,41 @@ enum ApplicationIncarnation { else { return rejected() } - return ["ok": true, "state": classify(recorded: startedAt, observed: observe(pid)).rawValue] + let state = ServiceIdentity.isTimestamp(text) + ? classifyExact(recorded: text, observed: observeExact(pid)) + : classify(recorded: startedAt, observed: observe(pid)) + return ["ok": true, "state": state.rawValue] + } + + static func classifyExact(recorded: String, observed: ExactObservation) -> State { + guard ServiceIdentity.isTimestamp(recorded) else { return .unknown } + switch observed { + case .gone: + return .exited + case .unavailable: + return .unknown + case .started(let actual): + guard ServiceIdentity.isTimestamp(actual) else { return .unknown } + if actual == recorded { return .running } + return actual > recorded ? .exited : .unknown + } + } + + static func observeExact(_ pid: pid_t) -> ExactObservation { + do { + return .started(try ServiceIdentity.inspect(pid).startedAt) + } catch let refusal as ServiceIdentity.Refusal where refusal.reason == "exited" { + return .gone + } catch { + return .unavailable + } + } + + static func captureCausalIdentity(matchesProcess: () -> Bool, inspect: () -> String?) -> String? { + guard matchesProcess(), let startedAt = inspect(), ServiceIdentity.isTimestamp(startedAt), matchesProcess() else { + return nil + } + return startedAt } static func classify(recorded: Date, observed: Observation) -> State { diff --git a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/ApplicationIncarnationSelfTest.swift b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/ApplicationIncarnationSelfTest.swift index 30b2512376..e58c85fdd0 100644 --- a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/ApplicationIncarnationSelfTest.swift +++ b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/ApplicationIncarnationSelfTest.swift @@ -13,6 +13,27 @@ extension SelfTestRunner { check(classify(.started(recorded.addingTimeInterval(5))) == .exited, "application incarnation is exited after pid reuse") check(classify(.started(recorded.addingTimeInterval(0.5))) == .unknown, "application incarnation is unknown near its start") check(classify(.started(recorded.addingTimeInterval(-5))) == .unknown, "application incarnation is unknown before its start") + let exact = "2023-11-14T22:13:20.123456Z" + check(ApplicationIncarnation.classifyExact(recorded: exact, observed: .started(exact)) == .running, + "exact application incarnation matches without rounding") + check(ApplicationIncarnation.classifyExact(recorded: exact, observed: .started("2023-11-14T22:13:20.123457Z")) == .exited, + "exact application incarnation detects PID reuse one microsecond later") + check(ApplicationIncarnation.classifyExact(recorded: exact, observed: .started("2023-11-14T22:13:20.123455Z")) == .unknown, + "an earlier exact identity is not proof of recorded exit") + check(ApplicationIncarnation.classifyExact(recorded: exact, observed: .unavailable) == .unknown, + "exact application incarnation fails closed on permission failure") + check(ApplicationIncarnation.captureCausalIdentity(matchesProcess: { true }, inspect: { exact }) == exact, + "causal process capture retains an exact kernel identity") + var causalChecks = 0 + let replaced = ApplicationIncarnation.captureCausalIdentity(matchesProcess: { + causalChecks += 1 + return causalChecks == 1 + }, inspect: { exact }) + check(replaced == nil && causalChecks == 2, "causal process capture rejects replacement during inspection") + check(ApplicationIncarnation.captureCausalIdentity(matchesProcess: { false }, inspect: { exact }) == nil, + "causal process capture refuses a foreign process") + check(ApplicationIncarnation.captureCausalIdentity(matchesProcess: { true }, inspect: { nil }) == nil, + "causal process capture fails closed when the kernel identity is unavailable") func answer(_ text: String?) -> JSONObject { ApplicationIncarnation.handle(text.map { Data($0.utf8) }) @@ -31,5 +52,48 @@ extension SelfTestRunner { check((own["ok"] as? Bool) == true && own["state"] as? String == "running", "application incarnation finds this process") let reused = answer(#"{"pid":\#(getpid()),"startedAt":"1970-01-01T00:00:00.000Z"}"#) check(reused["state"] as? String == "exited", "application incarnation treats a later start as pid reuse") + testExactApplicationExclusion() + testIdentityInspectionRefusals() + } + + private func testIdentityInspectionRefusals() { + let driver = Driver() + let requests: [JSONObject] = [ + [:], + ["application": ["processIdentity": ["processId": getpid(), "processStartedAt": "not-a-native-timestamp"], "windowTitle": "Never inspected"]], + ["application": ["processIdentity": ["processId": getpid(), "processStartedAt": "1970-01-01T00:00:00.000000Z"], "windowTitle": "Never inspected"]], + ] + for request in requests { + do { + _ = try driver.execute(command: "inspectApplication", params: request, token: CancellationToken()) + check(false, "read-only application inspection rejects an unverified descriptor") + } catch { + let code = (error as? HelperError)?.code + check(code == ErrorCode.invalidParams || code == ErrorCode.leaseInvalid, + "the identity command fails closed on malformed or stale identity before Accessibility discovery") + } + } + } + + private func testExactApplicationExclusion() { + do { + let own = try ServiceIdentity.inspect(getpid()) + let first = try ExactApplicationLock.acquire(processID: getpid(), startedAt: own.startedAt) + defer { first.release() } + do { + let unexpected = try ExactApplicationLock.acquire(processID: getpid(), startedAt: own.startedAt) + unexpected.release() + check(false, "two application owners cannot acquire the same exact incarnation") + } catch { + check((error as? HelperError)?.data?["reason"] as? String == "application-lock-busy", + "actual lock contention is distinguished from filesystem and descriptor failures") + } + first.release() + let next = try ExactApplicationLock.acquire(processID: getpid(), startedAt: own.startedAt) + next.release() + check(true, "application exclusion is released after exact lease cleanup") + } catch { + check(false, "exact application exclusion can inspect and lock this process: \(error)") + } } } diff --git a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/ApplicationLock.swift b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/ApplicationLock.swift new file mode 100644 index 0000000000..09cde5dbb8 --- /dev/null +++ b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/ApplicationLock.swift @@ -0,0 +1,87 @@ +import Darwin +import Foundation + +/// One controlling helper for an exact application incarnation, independent of checkouts and TMPDIR. +final class ExactApplicationLock: @unchecked Sendable { + private var descriptor: Int32 + + private init(_ descriptor: Int32) { + self.descriptor = descriptor + } + + static func acquire(processID: pid_t, startedAt: String) throws -> ExactApplicationLock { + guard ServiceIdentity.isTimestamp(startedAt) else { + try fail(ErrorCode.leaseInvalid, "Application exclusion requires an exact native process identity.") + } + let root = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) + .appendingPathComponent("Library/Caches/com.microsoft.fluentui-react-native.desktop-driver/applications", isDirectory: true) + do { + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) + } catch { + try fail(ErrorCode.attachFailed, "The per-user application exclusion directory could not be created.") + } + var directory = stat() + guard lstat(root.path, &directory) == 0, + directory.st_uid == getuid(), + directory.st_mode & S_IFMT == S_IFDIR, + directory.st_mode & 0o077 == 0 + else { + try fail(ErrorCode.attachFailed, "The application exclusion directory is not an owner-only directory.") + } + let stamp = startedAt.filter { $0.isASCII && $0.isNumber } + let filename = root.appendingPathComponent("\(processID)-\(stamp).lock") + let fd = open(filename.path, O_CREAT | O_RDWR | O_CLOEXEC | O_NOFOLLOW, S_IRUSR | S_IWUSR) + guard fd >= 0 else { + try fail(ErrorCode.attachFailed, "The application exclusion record could not be opened.") + } + var info = stat() + guard fstat(fd, &info) == 0, info.st_uid == getuid(), info.st_mode & S_IFMT == S_IFREG, + info.st_mode & 0o077 == 0, info.st_nlink == 1 + else { + close(fd) + try fail(ErrorCode.attachFailed, "The application exclusion record is not an owner-only regular file.") + } + guard flock(fd, LOCK_EX | LOCK_NB) == 0 else { + let failure = errno + close(fd) + throw lockFailure(failure) + } + return ExactApplicationLock(fd) + } + + static func lockFailure(_ error: Int32) -> HelperError { + if error == EWOULDBLOCK { + return HelperError(code: ErrorCode.attachFailed, + message: "Another desktop service controls this exact application incarnation.", + data: ["reason": "application-lock-busy"]) + } + return HelperError(code: ErrorCode.attachFailed, message: "The exact application exclusion lock failed.", + data: ["reason": "application-lock-unavailable", "errno": Int(error)]) + } + + /// A proven launch is cleaned up on local lock failures, but never signalled through another helper's lock. + static func acquireLaunched( + processID: pid_t, startedAt: String, launchKind: String, + acquire: (pid_t, String) throws -> ExactApplicationLock = ExactApplicationLock.acquire + ) throws -> ExactApplicationLock { + do { + return try acquire(processID, startedAt) + } catch { + if (error as? HelperError)?.data?["reason"] as? String == "application-lock-busy" { + throw HelperError(code: ErrorCode.launchFailed, message: "The launched application could not be exclusively owned.", + data: ["processId": Int(processID), "processStartedAt": startedAt, + "launchKind": launchKind, "applicationSettlement": "unknown"]) + } + throw abandonedLaunch(processID: processID, startedAt: startedAt, launchKind: launchKind, error: error) + } + } + + func release() { + if descriptor < 0 { return } + _ = flock(descriptor, LOCK_UN) + close(descriptor) + descriptor = -1 + } + + deinit { release() } +} diff --git a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/ApplicationTermination.swift b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/ApplicationTermination.swift index 64c24d5dce..904247d339 100644 --- a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/ApplicationTermination.swift +++ b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/ApplicationTermination.swift @@ -48,10 +48,15 @@ func terminateOwned( startedAt: Date, bounds: TerminationBounds, token: CancellationToken? = nil, - control: ProcessControl = .system + control: ProcessControl = .system, + exactStartedAt: String? = nil, + observeExact: (pid_t) -> ApplicationIncarnation.ExactObservation = ApplicationIncarnation.observeExact ) throws { func state() -> ApplicationIncarnation.State { - ApplicationIncarnation.classify(recorded: startedAt, observed: control.observe(processID)) + if let exactStartedAt { + return ApplicationIncarnation.classifyExact(recorded: exactStartedAt, observed: observeExact(processID)) + } + return ApplicationIncarnation.classify(recorded: startedAt, observed: control.observe(processID)) } func waitForExit(milliseconds: Int) throws -> Bool { let deadline = DispatchTime.now() + .milliseconds(max(0, milliseconds)) @@ -105,15 +110,18 @@ func terminateOwned( @discardableResult func terminateUnleased( processID: pid_t, + startedAt: String? = nil, bounds: TerminationBounds, control: ProcessControl = .system ) -> Bool { - // A process whose start time cannot be read is either gone or not ours to signal. - guard let startedAt = processStartTime(processID) else { - return control.observe(processID).isGone + // Never adopt whichever incarnation happens to occupy a launch PID when cleanup eventually runs. + guard let startedAt, let date = parseDate(startedAt), ServiceIdentity.isTimestamp(startedAt) else { + return ApplicationIncarnation.observeExact(processID).isGone } do { - try terminateOwned(processID: processID, startedAt: startedAt, bounds: bounds, control: control) + try terminateOwned( + processID: processID, startedAt: date, bounds: bounds, control: control, exactStartedAt: startedAt + ) return true } catch { writeDiagnostic("furn-desktop-driver-host: ending abandoned launch \(processID) failed: \(error)") @@ -121,6 +129,25 @@ func terminateUnleased( } } +/// Only a causally captured launch identity grants cleanup authority. A survivor stays named and unsettled. +func abandonedLaunch(processID: pid_t, startedAt: String?, launchKind: String, error: Error) -> Error { + let exited = terminateUnleased(processID: processID, startedAt: startedAt, bounds: .abandoned) + var data = (error as? HelperError)?.data ?? (error as? CancelledError)?.data ?? [:] + data["processId"] = Int(processID) + data["launchKind"] = launchKind + data["applicationSettlement"] = exited ? "exited" : "unknown" + if let startedAt { data["processStartedAt"] = startedAt } + if let cancellation = error as? CancelledError { + return CancelledError(effect: cancellation.effect, data: data) + } + return HelperError( + code: ErrorCode.launchFailed, + message: exited ? "The application launch failed; its exact process exited. \(error)" : + "The launch failed and the application process it started could not be proven exited.", + data: data + ) +} + extension ApplicationIncarnation.Observation { var isGone: Bool { if case .gone = self { @@ -129,3 +156,10 @@ extension ApplicationIncarnation.Observation { return false } } + +extension ApplicationIncarnation.ExactObservation { + var isGone: Bool { + if case .gone = self { return true } + return false + } +} diff --git a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/ApplicationTerminationSelfTest.swift b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/ApplicationTerminationSelfTest.swift index a39da1a3cb..f8c1bf11cb 100644 --- a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/ApplicationTerminationSelfTest.swift +++ b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/ApplicationTerminationSelfTest.swift @@ -129,8 +129,88 @@ extension SelfTestRunner { attempt(interrupted, token: cancelled) is CancelledError && interrupted.kills == 0, "a cancelled close stops waiting before it escalates to a kill" ) + let exact = "2023-11-14T22:13:20.250000Z" + let protected = FakeProcess(startedAt: started) + do { + try terminateOwned(processID: 4242, startedAt: started, bounds: bounds, control: protected.control, + exactStartedAt: exact, observeExact: { _ in .started("2023-11-14T22:13:20.250001Z") }) + check(protected.quits == 0 && protected.kills == 0, "exact cleanup never signals a PID reused one microsecond later") + } catch { + check(false, "exact cleanup recognizes proven PID reuse") + } + do { + try terminateOwned(processID: 4242, startedAt: started, bounds: bounds, control: protected.control, + exactStartedAt: exact, observeExact: { _ in .unavailable }) + check(false, "exact cleanup refuses an unreadable incarnation") + } catch { + check(protected.quits == 0 && protected.kills == 0, "exact cleanup refuses an unreadable incarnation without signalling") + } testRealProcessTermination() + testLaunchLockFailure() + testRejectedLaunchSettlement() + } + + private func testRejectedLaunchSettlement() { + let manager = ApplicationManager() + let requests: [JSONObject] = [ + [:], + ["application": ["processIdentity": ["processId": 1]]], + ["application": [:], "launchTimeoutMs": 0], + ["application": ["executablePath": "/dev/null/furn-missing-executable", "windowTitle": "Never launched"]], + ["application": ["bundleIdentifier": "com.microsoft.furn.nonexistent-self-test", "windowTitle": "Never launched"]], + ] + for request in requests { + do { + _ = try manager.launch(request, token: CancellationToken()) + check(false, "a rejected launch cannot acquire an application") + } catch { + check((error as? HelperError)?.data?["applicationSettlement"] as? String == "not-started", + "a pre-spawn rejection proves that no application started") + } + } + } + + private func testLaunchLockFailure() { + for failure in [EWOULDBLOCK, EBADF] { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sleep") + process.arguments = ["60"] + do { + try process.run() + } catch { + check(false, "the failed-lock self-test can start a child") + continue + } + defer { + if process.isRunning { kill(process.processIdentifier, SIGKILL) } + process.waitUntilExit() + } + do { + let identity = try ServiceIdentity.inspect(process.processIdentifier) + do { + let unexpected = try ExactApplicationLock.acquireLaunched( + processID: process.processIdentifier, startedAt: identity.startedAt, launchKind: "direct-child", + acquire: { _, _ in throw ExactApplicationLock.lockFailure(failure) } + ) + unexpected.release() + check(false, "a failed application lock never grants ownership") + } catch { + let settlement = (error as? HelperError)?.data?["applicationSettlement"] as? String + if failure == EWOULDBLOCK { + check(settlement == "unknown" && + ApplicationIncarnation.classifyExact(recorded: identity.startedAt, + observed: ApplicationIncarnation.observeExact(process.processIdentifier)) == .running, + "lock contention preserves the exact application controlled by another helper") + } else { + check(settlement == "exited" && ApplicationIncarnation.observeExact(process.processIdentifier).isGone, + "a non-contention lock failure ends the proven launch and reports exact exit") + } + } + } catch { + check(false, "the failed-lock self-test can inspect its exact child: \(error)") + } + } } private func testRealProcessTermination() { diff --git a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Driver.swift b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Driver.swift index 6e673769ad..ff8bf04b99 100644 --- a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Driver.swift +++ b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Driver.swift @@ -64,9 +64,10 @@ final class Driver { error.effect = effects.effect } throw error - } catch is CancelledError { + } catch var error as CancelledError { applications.commands.finish(entry, outcome: "cancelled", detail: failureDetail(tracked)) - throw CancelledError(effect: tracked ? effects.effect : nil) + if tracked { error.effect = effects.effect } + throw error } catch { applications.commands.finish(entry, outcome: "internal-error", detail: failureDetail(tracked)) guard tracked else { @@ -107,6 +108,8 @@ final class Driver { return CommandResult(result: try applications.launch(params, token: token).json()) case "attach": return CommandResult(result: try applications.attach(params, token: token).json()) + case "inspectApplication": + return CommandResult(result: try applications.inspectApplication(params, token: token)) case "closeApplication": let lease = try requireObject(params["lease"], "The closeApplication request is missing its lease.") let leaseID = try requireString(lease, "id") diff --git a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Host.swift b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Host.swift index c8d2112e2a..674c20e0a7 100644 --- a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Host.swift +++ b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Host.swift @@ -23,6 +23,7 @@ func createHello() -> JSONObject { "elementCapture", "find", "hitTest", + "inspectApplication", "keyboard", "launch", "performAccessibilityAction", @@ -289,7 +290,7 @@ final class Host { if let cancellation { driver.releaseInput() - writeCancelled(request.id, effect: cancellation.effect) + writeCancelled(request.id, effect: cancellation.effect, data: cancellation.data) } condition.lock() activeID = nil @@ -371,7 +372,7 @@ final class Host { } } - private func writeCancelled(_ id: String, effect: MutationEffect? = nil) { + private func writeCancelled(_ id: String, effect: MutationEffect? = nil, data: JSONObject? = nil) { var message: JSONObject = [ "type": "cancelled", "id": id, @@ -379,6 +380,7 @@ final class Host { if let effect { message["effect"] = effect.rawValue } + if let data { message["data"] = data } do { try writer?.writeJSON(message) } catch { diff --git a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/ServiceIdentity.swift b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/ServiceIdentity.swift index 3b224026a4..ac52fe2b5c 100644 --- a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/ServiceIdentity.swift +++ b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/ServiceIdentity.swift @@ -360,7 +360,7 @@ enum ServiceIdentity { } /// Lists the processes whose effective user is the current user. - private static func ownProcesses() throws -> [pid_t] { + static func ownProcesses() throws -> [pid_t] { let user = getuid() var capacity = Int(proc_listpids(UInt32(PROC_UID_ONLY), user, nil, 0)) / MemoryLayout.size for _ in 0.. Bool { true } +} + +@main +struct FixtureMain { + @MainActor static func main() { + let application = NSApplication.shared + let delegate = ApplicationFixture() + application.delegate = delegate + application.setActivationPolicy(.regular) + withExtendedLifetime(delegate) { application.run() } + } +} diff --git a/packages/agentic/desktop-driver/native/macos/fixtures/build.cjs b/packages/agentic/desktop-driver/native/macos/fixtures/build.cjs new file mode 100644 index 0000000000..62b084cece --- /dev/null +++ b/packages/agentic/desktop-driver/native/macos/fixtures/build.cjs @@ -0,0 +1,51 @@ +const { spawnSync } = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); + +if (process.platform !== 'darwin') throw new Error('The AppKit application fixture requires macOS.'); +if (!process.argv[2]) throw new Error('Usage: node native/macos/fixtures/build.cjs '); + +const directory = path.resolve(process.argv[2]); +const bundle = path.join(directory, 'FurnOwnershipFixture.app'); +const executable = path.join(bundle, 'Contents', 'MacOS', 'fixture'); +const bundleIdentifier = 'com.microsoft.furn.application-ownership-fixture'; +const minimumMacOSVersion = '11.0'; +fs.mkdirSync(path.dirname(executable), { recursive: true }); + +function run(command, args) { + const result = spawnSync(command, args, { encoding: 'utf8', timeout: 120_000 }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`${command} failed: ${result.stderr || result.stdout}`); +} + +run('xcrun', [ + 'swiftc', + '-target', + `arm64-apple-macosx${minimumMacOSVersion}`, + '-parse-as-library', + '-framework', + 'AppKit', + path.join(__dirname, 'ApplicationFixture.swift'), + '-o', + executable, +]); +fs.writeFileSync( + path.join(bundle, 'Contents', 'Info.plist'), + [ + '', + '', + '', + `CFBundleIdentifier${bundleIdentifier}`, + 'CFBundleNameFurnOwnershipFixture', + 'CFBundleExecutablefixture', + 'CFBundlePackageTypeAPPL', + 'CFBundleVersion1', + `LSMinimumSystemVersion${minimumMacOSVersion}`, + 'NSPrincipalClassNSApplication', + '', + '', + ].join('\n'), +); +run('codesign', ['--force', '--sign', process.env.FURN_DESKTOP_DRIVER_MACOS_SIGNING_IDENTITY || '-', '--timestamp=none', bundle]); +run('codesign', ['--verify', '--strict', bundle]); +process.stdout.write(`${JSON.stringify({ bundle, executable, bundleIdentifier, windowTitle: 'FURN Application Ownership Fixture' })}\n`); diff --git a/packages/agentic/desktop-driver/native/protocol.json b/packages/agentic/desktop-driver/native/protocol.json index 089da6c656..8bb80c8cdd 100644 --- a/packages/agentic/desktop-driver/native/protocol.json +++ b/packages/agentic/desktop-driver/native/protocol.json @@ -9,5 +9,35 @@ "headerBytes": 12, "jsonType": 1, "binaryType": 2 + }, + "macosApplicationOwnership": { + "processIdentity": { + "type": "object", + "additionalProperties": false, + "required": ["processId", "processStartedAt"], + "properties": { + "processId": { "type": "integer", "minimum": 1, "maximum": 2147483647 }, + "processStartedAt": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{6}Z$" + } + } + }, + "identityInspection": { + "command": "inspectApplication", + "result": { "$ref": "#/macosApplicationOwnership/processIdentity" }, + "acquiresLease": false, + "registersWindows": false + }, + "launchKind": ["launch-services", "direct-child"], + "containment": ["uncontained", "external"], + "applicationSettlement": ["not-started", "exited", "unknown"], + "launchServicesErrorFields": { + "launchErrorDomain": { "type": "string", "maxLength": 163 }, + "launchErrorCode": { "type": "integer" }, + "underlyingErrorDomain": { "type": "string", "maxLength": 163 }, + "underlyingErrorCode": { "type": "integer" } + }, + "failureMetadataLocations": ["response.error.data", "cancelled.data"] } } diff --git a/packages/agentic/desktop-driver/references/contributing.md b/packages/agentic/desktop-driver/references/contributing.md index 4232789000..91a05f1cd0 100644 --- a/packages/agentic/desktop-driver/references/contributing.md +++ b/packages/agentic/desktop-driver/references/contributing.md @@ -99,6 +99,10 @@ specifiers, so keep it that way. lock. - Add native self-tests for provider-local parsing, state, cancellation, identity, capture geometry, or recovery changes. +- On macOS, observers use `NativeDesktopHost.inspectApplication`, not another + controlling `attach`. It validates the configured descriptor and exact window + without taking an application lock or changing session window/element records. + Keep the exclusive per-incarnation lock on every real launch/attach. - Add or update the opt-in Node native contract when build, cache, selection, signing, or handshake behavior changes. - On Windows, extend the Win32 fixture contract diff --git a/packages/agentic/desktop-driver/references/current-status.md b/packages/agentic/desktop-driver/references/current-status.md index f205605b8f..a5a875fccc 100644 --- a/packages/agentic/desktop-driver/references/current-status.md +++ b/packages/agentic/desktop-driver/references/current-status.md @@ -23,10 +23,9 @@ and its sub-issues, which record dependencies and parallel waves. | ---------------------------------------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | W3C service and typed/WDIO clients | Done | Registered targets only; native browser-inapplicable commands explicitly unsupported. | | Application-neutral driver | Done | No Storybook, React, or React Native dependency; package-boundary tests reject forbidden imports/dependencies. Optional story provider is injected. | -| Native sessions and command/input queues | Done | One reservation per registered target in one service. Cross-service exact-application claims remain work in progress. | +| Native sessions and command/input queues | Done | One reservation per registered target in one service. Exact macOS application exclusion and the physical-input mutex passed signed AppKit contracts on 2026-11-12. Renderer and failure-path qualification remains separately scoped below. Windows behavior is unchanged. | | Verified private discovery and grants | Done | Native identity verified before credential transmission; all protected driver routes authenticate. Storybook agent manifests additionally disable alternate channel mutations in the integration adapter. | | Attenuated connection principals and permission scopes | Done in deterministic contracts | Driver/workload/recovery scopes are distinct; fixed driver-only HTTP delegation, target/scope subsets, synchronous subtree fencing and awaitable/retryable drain. Native descriptor-path requalification not run for this change. | -| Scoped attach-only runner handoff | Done in deterministic contracts | Workload connection reserves one generic attached target only after confirmed old-controller drain. Descendants inherit attach-only scope; parent reservations exclude MCP between workers. Busy is the default, failed cleanup stays fenced, preemption needs explicit recovery authority. Storybook, retained launches, and native endpoint qualification remain separate. | | Mutation effects, settlement, quarantine | Done | Native mutations settle before advancing. Uncertain story changes now quarantine instead of assuming a later render is safe. | | Durable settlement storage and trusted recovery API | Done | Generic/Storybook native paths share the harness factory's private storage and exact-incarnation probes. Direct library embedders must pass `recovery`; restart is not proof. | | Bounded observation, references, paging, deltas | Done | Provider capability/budget checks, explicit completeness and truncation. Reference generation is session-local; reopen after application replacement. | @@ -52,14 +51,14 @@ stable-signed helper reports Accessibility/PostEvent true without prompting. This alone is not requalification; see the [2026-11-12 supporting evidence](#f15-non-ax-macos-supporting-evidence--2026-11-12). -| Lane | Recorded evidence through 2026-11-06 | Still required before full submission | -| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| macOS Fabric | M3 evidence at `41fc7473f`; bounded F4 marker, stale-preview/chrome and authenticated-incarnation restart checks at `6fca14788` | Native cancellation/fault injection, fresh preview after lost context, secondary windows, physical input, supervisor crash, generic AppKit fixture and reliability window. | -| macOS Paper | Hosted build/smoke evidence; separate bounded F4 marker, stale-preview/chrome and authenticated-incarnation restart checks at `6fca14788` | Native cancellation/fault injection, fresh preview after lost context, secondary windows, physical input and complete lifecycle/reliability qualification. | -| Windows Fabric | M3 real-app observation and story MCP path recorded at `600235c35` | Same-branch lifetime/restart and failure gates; 200%/mixed DPI, minimized/negative-origin windows, live lock/RDP/elevation, popups, capture cancellation. | -| Win32 Paper | M3/agent path recorded; native fixture physical input/capture at 100% and 150% | Interactive rerun of current native contracts; physical Ctrl+C, mixed DPI and failure paths. Hosted input/capture skips are not passes. | -| Generic native applications | macOS Calculator and plain Win32 fixture evidence recorded | AppKit fixture and integrated ownership/recovery parity; a probe is not a real-app lifecycle qualification. | -| This update's portable changes | Deterministic fake-host MCP, authority, and story regression coverage; the Windows oversized-tree fallback passed hosted CI | CI and matching-OS live evidence are distinct. The oversized-tree fallback is compiled and contract-tested only on hosted CI; no live Windows qualification. | +| Lane | Recorded evidence through 2026-11-12 | Still required before full submission | +| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| macOS Fabric | M3 at `41fc7473f`; bounded F4/restart at `6fca14788`; signed neutral ownership/inspection/exclusion and 224 accessible smoke checks on 2026-11-12 | Native cancellation/fault injection, fresh preview after lost context, secondary windows, physical pointer/normal-keyboard/capture paths, supervisor crash, descendants, interrupted publication and reliability window. | +| macOS Paper | Hosted build/smoke and bounded F4/restart at `6fca14788`; separately signed neutral ownership/inspection/exclusion and 224 accessible smoke checks on 2026-11-12 | Native cancellation/fault injection, fresh preview after lost context, secondary windows, physical pointer/normal-keyboard/capture paths and complete failure/reliability qualification. | +| Windows Fabric | M3 real-app observation and story MCP path recorded at `600235c35` | Same-branch lifetime/restart and failure gates; 200%/mixed DPI, minimized/negative-origin windows, live lock/RDP/elevation, popups, capture cancellation. | +| Win32 Paper | M3/agent path recorded; native fixture physical input/capture at 100% and 150% | Interactive rerun of current native contracts; physical Ctrl+C, mixed DPI and failure paths. Hosted input/capture skips are not passes. | +| Generic native applications | Historical macOS Calculator/plain Win32 evidence; signed AppKit ownership and physical-input mutex passed on 2026-11-12 | Integrated survivor/interrupted-publication/recovery parity; a probe alone is not real-app lifecycle qualification. | +| This update's portable changes | Deterministic fake-host MCP, authority, and story regression coverage; the Windows oversized-tree fallback passed hosted CI | CI and matching-OS live evidence are distinct. The oversized-tree fallback is compiled and contract-tested only on hosted CI; no live Windows qualification. | Hosted Windows policy disables physical input and can reject capture with `0x80070057`. Capability-gated skips must stay explicit. `activate` succeeding @@ -479,6 +478,377 @@ probe timed out after 10 seconds; bridge retirement occurred about 19 seconds after injection. A later attempt waited for retirement and retained the same Partial result. Physical Ctrl+C and DPI changes were not exercised. +## 2026-11-12: macOS exact application identity backend + +Source `76a2be59abfde3a7bcb0abd8d86a2052e7378274`, macOS 27.2.1 arm64, +standalone AppKit fixture; no React renderer. Exact kernel start timestamps, +causal direct-child/LaunchServices capture, exact-primary cleanup and stable +per-user exact-app exclusion are implemented. Both macOS launch kinds remain +uncontained. Unknown or stale identity never authorizes termination. +The generic lease/claim policy lives in `desktop-test`, not in the helper. + +Under the exclusive native-validation lock, with `FURN_NATIVE_DRIVER_TEST=1`, +stable signing identity `FURN Desktop Driver Development`, project-local +temporary storage and an isolated helper cache: + +- At source `fc1df4bda42004ad86378d2acc6701427dda1836`, + `yarn workspace @fluentui-react-native/desktop-driver test --runInBand` + passed 1,241, failed 0, skipped 47; 75 suites passed, 6 skipped. + Artifact: `artifacts/desktop/f10m/driver-native-stable.log`. + This includes signed build/rebuild and causal-identity native self-tests. + An earlier build-ID mismatch was caused by editing native inputs while the + run was queued; this successful rerun used frozen source. +- At source `76a2be59abfde3a7bcb0abd8d86a2052e7378274`, + `yarn workspace @fluentui-react-native/desktop-driver test --runInBand applicationIncarnation serviceIdentity` + passed 69, failed 0, skipped 14; 4 suites passed, 2 skipped. + Artifact: `artifacts/desktop/f10m/native-identity-final.log`. + The intervening fixture deployment-target/schema-bound correction was + separately exercised by the harness native safety cases. +- The harness's native OS-owner and rejected-launch cases passed 3 with + 5 skipped. Actual AX calls returned `apiDisabled` (-25211), but exact + direct-child and LaunchServices primary cleanup and claim-resource records + were proved. Both recorded PIDs and all own-worktree fixture/helper + processes were absent afterward. The corrected fixture's Mach-O minimum + is macOS 11.0, matching the helper. + +Exact helper artifact/digest, commands, PIDs, failures, cleanup and evidence +locators are in `packages/agentic/desktop-test/references/current-status.md`, +section "2026-11-12: F10-M neutral application ownership". +Positive application lifecycle/external preservation and cross-service +application exclusion remain blocked by missing Accessibility permission; +the physical-input mutex is not qualified, and PostEvent preflight is false. +macOS Fabric and Paper signing/TCC-responsibility qualification remain +separate, unrun gates on this branch. No TCC/privacy settings were changed. +Primary exit is not descendant containment; Windows/Win32 native application +claim/survivor qualification remains not run for this change. + +### 2026-11-12 review follow-up + +At source `62e6552028ea8fb81e76d430379321145fab709c`, the signed helper +was forcibly rebuilt and +`yarn workspace @fluentui-react-native/desktop-driver test --runInBand nativeDriver.test applicationIncarnation serviceIdentity` +passed 79, failed 0, skipped 15; 5 suites passed, 2 skipped. The lock, signing, +native opt-in and project-local storage policy above were retained. +The native self-test exercises actual flock contention, preserves a real child +after injected EWOULDBLOCK and proves exact cleanup of a real launched child +after injected EBADF. Pre-spawn failures report `not-started`; a lost dispatched +launch response remains `unknown`. Default-TMPDIR transport regressions passed +59/59. + +The matching AppKit safety subset passed 4, failed 0, skipped 5, including +pre-spawn/LaunchServices no-app rejection and claim reuse. Its helper artifact is +`1d68fae8df504432900e30e5b2a6714029762f5dd690cd953a8ef07b1f5da4f3`, +source digest +`2c8ac56b2b824b05b1cb10d358050dfb643b44eb6e209508d53eaa0f2744b0f8`. +Raw evidence is +`packages/agentic/desktop-test/artifacts/desktop/application-ownership-JMEGuu/evidence.json`; +commands and exact primary identities are in the harness ledger. +Positive AX and renderer qualification remain blocked/not run as above. + +The final review-fix `yarn clean-lage-cache && yarn lage buildci --concurrency 4` +passed under the exclusive lock at clean source +`e74043b0e6eb5b452e9023bc60c70ba6c9540600`: 178 successful tasks, +0 skipped/pending/aborted/failed. Project-local TMPDIR/Git-ceiling isolation and +the declared `TF_BUILD=1` in-band test task were used, with native-only +signing/cache/opt-in overrides removed. Log: +`artifacts/desktop/f10m/review-buildci-final.log`. +The harness ledger records the exact invocation. This aggregate is portable +repository validation, not additional positive native qualification. + +### 2026-11-12 Storybook identity-observation regression + +CI run `43148275374` at `0bd0ad6f7` failed both macOS Storybook renderers: +the normal driver session held the exact-application flock, while bridge-epoch +recovery created another helper and tried a competing controlling `attach`. +The passing macOS E2E artifact has 62 tests using session-owned `launch`; +it does not exercise that Storybook identity callback. + +Fix source `5e360734e9b85bd4732aabc47c7654bb7211c3d3` adds +`NativeDesktopHost.inspectApplication` and the matching macOS native command. +It shares descriptor/legacy-lease/window validation and checks the exact +kernel incarnation before and after discovery, without creating a lease, +acquiring application/input locks or registering windows/elements. Storybook +uses the session's helper for this observation. Real launch/attach exclusion +is unchanged; foreign control remains `application-lock-busy`. +Windows/Win32 retain their existing temporary identity attachment, and an old +macOS helper never falls back to controlling attachment. + +Default-TMPDIR adapter regressions passed 11; composed Fabric/Paper Storybook +regressions passed 5; claim/ownership regressions passed 29. The full portable +driver suite passed 1,236 with 66 skipped, and all three owning packages passed +format/build/lint and their full suites. Broad fixtures require project-local +TMPDIR under this task's filesystem restriction; only the focused selections +are default-TMPDIR evidence. + +Under the exclusive native-validation lock, the stable-signed helper was +forcibly rebuilt. Native identity/termination/backend contracts passed 79 with +15 skipped, including new inspection-refusal self-tests; AppKit safety passed +4 with 5 skipped. Helper artifact: +`982f3cfa696bbde7f9868186ae9365d59014fd656687e5cbec2de74863280ed3`, +source digest +`6bafaf5835d9cd22410992466a8d8f86086ff6b3b3efd182b3a48922f698527c`. +The noninteractive doctor still reports Accessibility/PostEvent preflight false. +Both rejected-launch primaries and all own-worktree fixture/helper processes +were absent after the run. No privacy changes were made. + +The harness ledger records exact commands, identities and evidence locators. +Raw evidence is under `artifacts/desktop/f10m/ci-regression/`. +Positive AX-dependent ownership/observation contracts and actual macOS +Storybook Fabric/Paper CI requalification remain pending, not passed; the +other native acceptance gates above are unchanged. + +The final clean-cache aggregate at `a7c37b86a` passed **178 tasks**, with 0 +skipped, failed, pending or aborted, under the exclusive native-validation +lock: `yarn clean-lage-cache && yarn lage buildci --concurrency 1`. +It used `TF_BUILD=1`, project-local TMPDIR and the matching Git ceiling, with +native-only overrides removed. The harness ledger records the full invocation. +The first concurrency-4 attempt failed after 112 successful tasks with abrupt +partial Storybook Jest output and no cause diagnostic. The unchanged isolated +Storybook suite passed 344 with 4 skipped under that exact environment before +the serialized aggregate retry. The first failure's cause is unestablished; +no source or test waiver was applied. Logs are `buildci.log`, +`storybook-buildci-environment.log` and `buildci-final.log` in the same artifact +directory. Runtime source remains `5e360734e`; this portable aggregate does +not qualify the still-pending positive native or renderer gates. + +### 2026-11-12 AX-enabled application-ownership qualification + +**Observed, superseding the permission-blocked results above.** Source is +`9395737943d7e1402a40a67a41dc605ef0afb24b` plus the live-discovery fix in +this delivery. Host: macOS 27.2.1 arm64. The signed, nonprompting doctor +reported Accessibility, PostEvent and ScreenCapture preflight `true`, unlocked +and on-console, with `promptRequested=false`. No privacy settings were changed. +Helper artifact: +`1429ce4663795bad31f545086ef80bffb83e9b9533287964991c5527388b2f6c`; +source digest: +`c17944691081d30a320cf6e4393a5bb0ce6cece8dd71295a400dbc5e6a53b3cb`. +The signer is `FURN Desktop Driver Development`, with designated requirement +`identifier "com.microsoft.fluentui-react-native.desktop-driver" and certificate leaf = H"ab592605f8619fcaa952b4a2525ae6d41296fdd7"`. + +The initial positive AppKit run passed 8 and failed 1: the session's window +existed, but its helper could not rediscover the newly launched app. +The new warmed-discovery regression failed before the fix (1 failed, +9 skipped). A read-only native diagnostic observed the child missing from +`NSWorkspace.shared.runningApplications` while direct PID and static bundle +queries saw it. The standalone helper does not pump the main run loop needed +to refresh that cached workspace list. Descriptor discovery now enumerates +current-user kernel PIDs and constructs fresh `NSRunningApplication` objects, +retaining exact bundle/executable/window, uniqueness and incarnation checks. +Enumeration failure refuses discovery; launch causality is unchanged. + +Under `$HOME/Library/Caches/furn-overnight/with-native-lock.sh`, +with the default TMPDIR unchanged: + +```sh +env FURN_NATIVE_DRIVER_TEST=1 FURN_NATIVE_PHYSICAL_INPUT_TEST=1 \ + FURN_DESKTOP_DRIVER_MACOS_SIGNING_IDENTITY="FURN Desktop Driver Development" \ + FURN_DESKTOP_DRIVER_CACHE_ROOT="$PWD/artifacts/desktop/f10m/helper" \ + yarn workspace @fluentui-react-native/desktop-test test --runInBand \ + applicationOwnership.contract applicationOwnership.test ownership.test \ + --cacheDirectory "$PWD/artifacts/desktop/f10m/ax-qualification-20261112/jest-native-harness" +``` + +**Passed 39, failed 0, skipped 0** across three files: 10 native ownership +tests and 29 portable regressions. Native cases cover supervisor lifetime +across two sessions, external preservation, independent-service exclusion, +lease release, fresh read-only discovery while another helper controls the app, +duplicate-bundle refusal, stale identity, causal primary cleanup, cancellation +and the physical-input mutex. The mutex uses only bounded Shift down/up, not +pointer input. AppKit evidence: +`packages/agentic/desktop-test/artifacts/desktop/application-ownership-1V0SrR/evidence.json`. +Raw red/green logs, the diagnostic and verified experimental-survivor cleanup +are in `artifacts/desktop/f10m/ax-qualification-20261112/`. + +The harness ledger's matching dated section owns the renderer commands, full-suite counts, +cleanup and remaining acceptance gates. AppKit evidence does not establish +descendant containment, interrupted native publication, automatic recovery, +or Windows/Win32 packaged-survivor behavior. + +**Observed, real renderers:** Fabric and Paper each passed all 9 qualification +checks, with 0 failed/skipped and 0 cleanup failures. Each run used the same +stable-signed helper above and independently rechecked nonprompting +Accessibility/PostEvent preflight. Neutral external owners preserved the app; +foreign native control returned `application-lock-busy`; a replacement +attached after lease release. Read-only inspection succeeded while the +controlling lease was held and retained its window handles. Supervisor-owned +launch survived two borrowed sessions, published exact application/owner +identity in schema 3 and exited at supervisor disposal. The transitional +Storybook CLI's outer claim remains `unregistered`; registered target/instance +checks apply to the neutral claims, not a fictional completed F9 migration. + +| Renderer | Exact smoke command from `apps/storybook` | Passed / failed / skipped / unsupported | Artifact | +| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | -------------------------------------------------------------------------------- | +| macOS Fabric | `STORYBOOK_SMOKE_STORY=components-button--default yarn storybook --reporter json smoke --macos --mode stories-and-tests --input accessible` | 224 / 0 / 0 / 0; retries 0 | `apps/storybook/artifacts/desktop/616bf2e8-a675-408f-bd56-2e09982ea969/run.json` | +| macOS Paper | `STORYBOOK_SMOKE_STORY=components-button--default yarn storybook --reporter json smoke --macos --paper --mode stories-and-tests --input accessible` | 224 / 0 / 0 / 0; retries 0 | `apps/storybook/artifacts/desktop/19358539-eed2-49e9-9c9a-79b5e7db4e9f/run.json` | + +Each smoke traversed 223 catalog entries and executed the one manifest-selected +Button semantics callback. One physical callback was excluded by input policy, +not passed or skipped. The physical-input mutex was separately qualified by +the AppKit test; these smokes are not pointer/capture qualification. +The exact run-owned and supervisor-owned apps exited, all reported +driver/channel/Metro ports closed and all test claims were released between +runs and at the end. Renderer evidence: +`artifacts/desktop/f10m/ax-qualification-20261112/{fabric,paper}/evidence.json`. +The harness ledger records the declared bundle/prep/run procedure, installed +Ruby JSON selection and failed qualifier attempts with their exact cleanup. + +**Not run/unqualified:** native descendant containment, interrupted native +publication, supervisor-death survivors, Windows/Win32 packaged-survivor +journaling, repeated-run reliability and clean-machine/PPPC/revoked-grant +behavior. Effective TCC acceptance is scoped to the observed `Rex Server.app` +responsibility tree, not an independently durable helper-owned grant. +Keep draft; ordinary hosted Windows driver/smoke CI is not sufficient for +the unrun harness survivor gate. + +Final declared package format checks, TypeScript builds and lint passed for +desktop-driver, desktop-test and storybook-desktop. The full signed, +native-enabled driver suite passed **1,255**, failed **0**, skipped **47**. +The separately scoped portable suites passed driver **1,236** (66 skipped), +harness **409** (11 skipped), and Storybook **344** (4 skipped), with no failures. +All used the default TMPDIR. Skips are not native passes; the signed focused +ownership run separately passed all 39, including all 10 AppKit tests. + +A broad mixed-environment harness attempt failed 3 (416 passed, 1 skipped): +two portable fixtures replace HOME, hiding the signing keychain while +inheriting the native signing override, and one LaunchServices acquisition +reported an unverifiable incarnation and preserved the process. Its cause is +unestablished; this is not reliable-launch qualification. That exact +run-owned fixture was operator-cleaned only after native identity/path/time +checks. The discovery-only regression now uses externally started children +with native identities, independent of LaunchServices ownership acquisition; +no causal guard was relaxed. The signed native selection and the portable +suites then passed separately. The harness ledger records exact commands, +failed-attempt evidence and the remaining causal-acquisition reliability gate. + +**Observed final repository validation:** the single final clean-cache +aggregate, `yarn clean-lage-cache && yarn lage buildci --concurrency 1`, +passed **178 tasks**, failed **0**, skipped **0**, pending **0**, aborted **0** +in 6m 50.47s under the native lock. It used `TF_BUILD=1`, the default TMPDIR, +and explicitly removed the five native/signing overrides listed in the +harness ledger. This portable aggregate supplements, not replaces, the +separate signed native and renderer results. Full invocation and output: +`artifacts/desktop/f10m/ax-qualification-20261112/buildci-final.log`. + +**Observed final cleanup:** after the aggregate, signed native probes +reported all **31** recorded exact process incarnations exited. Scoped +command-line and current-working-directory inspection found **0** worktree +workload processes; all three reported driver/channel/Metro ports refused +connections. The shared workload claim and all `interactive.lock` files in +eight native/renderer/failed-attempt artifact roots were absent. The +locked verifier exited successfully, and a subsequent locked `true` +reacquired and released the machine-wide lock. No privacy changes or +unknown-process termination were used. Evidence and operational verifier: +`artifacts/desktop/f10m/ax-qualification-20261112/cleanup-final.json`, +`cleanup-final.log` and `verify-cleanup.mjs` in that directory. +The experimental diagnostic/cleanup scratch sources were removed; their +JSON results and logs remain ignored evidence. + +Documentation closeout passed all four changed Markdown link checks +(12 / 6 / 15 / 0 links), `yarn lage test-links --verbose --grouped` +(90 tasks, 0 failed/skipped) and repository-wide `yarn oxfmt --check` +(3,159 files). The harness ledger records each exact link-check command. +Final logs in the same artifact root: `changed-markdown-links-final.log`, +`test-links-final.log` and `root-format-final.log`. + +### Rebased macOS publication/survivor contracts — 2026-11-12 + +The macOS ownership layer is rebased onto F6 main +`041ac8b9894aeaa7e1aa273baa2ece4e46298b94`; test/fixture qualification +commit `d5a7530a9f2d4cb78721a36c1109b5c36fbdc5a1` does not change +production helper behavior. Under the shared native lock on macOS 27.2.1 +arm64, the stable-signed native driver suite passed **1,256, failed 0, +skipped 47**: + +```sh +FURN_NATIVE_DRIVER_TEST=1 \ +FURN_DESKTOP_DRIVER_MACOS_SIGNING_IDENTITY="FURN Desktop Driver Development" \ +FURN_DESKTOP_DRIVER_CACHE_ROOT="$PWD/artifacts/desktop/f10m/helper" \ +yarn workspace @fluentui-react-native/desktop-driver test --runInBand \ + --cacheDirectory "$PWD/artifacts/desktop/f10m/rebased-qualification-20261112/jest-driver-native" +``` + +Package format, lint and build passed. Nonprompting doctor reported effective +Accessibility/PostEvent/ScreenCapture permissions and an unlocked console; +no privacy changes were made. Helper artifact/source digest are unchanged +from the previous signed qualification. + +The harness's focused suite passed **13 native AppKit cases plus 29 portable +cases, no failures or skips**. New native evidence covers failed acquired-record +publication, owner death before/after acquired-record publication, retained +workload fences, real application/known-descendant survivors and exact operator +cleanup. The descendant remains alive after the primary exits, consistent with +both macOS launch kinds remaining explicitly `uncontained`. This is not +automatic recovery or arbitrary descendant containment. Stale native start +identities and inaccessible foreign process identity still fail closed. + +Exact commands, process identities, limitations and the independent renderer +and aggregate outcomes are recorded in +`packages/agentic/desktop-test/references/current-status.md`, section +"Rebased F10-M publication and survivor qualification". +Evidence root: `artifacts/desktop/f10m/rebased-qualification-20261112/`; +AppKit identities: +`packages/agentic/desktop-test/artifacts/desktop/application-ownership-kffNMN/evidence.json`. + +The rebased real Storybook consumer passes at +`ca88c289c0fbd79b03dbd43b9bd85f0b21e7e800` independently passed all +**9 ownership checks on Fabric and all 9 on Paper**, with no cleanup failures. +Each accessible smoke passed **224/224**, with no failures/skips/unsupported +cases/retries; one physical callback was excluded before selection. +Neutral supervisor/session lifetime, exact external preservation, independent +application exclusion, read-only inspection and exact cleanup all passed. +This supplements the unchanged helper's native contracts and does not +qualify arbitrary descendants or deferred Storybook lifecycle integration. +Exact commands and report IDs are in the harness ledger. + +**Aggregate qualification remains failed:** one plain cache-cleared +`yarn clean-lage-cache && yarn lage buildci` encountered an unreproduced +desktop-test pack dry-run failure. Two unchanged plain `yarn lage buildci` +retries then reproduced the pre-existing `boundedCommand` parallel-startup +SIGKILL/SIGTERM assertion; the first also hit an unmodified private-storage +timeout, which passed in the last attempt. The focused serial diagnostic +passed 73 with 10 skipped. No environment/concurrency override or unrelated +test fix was used; this does not establish a green aggregate. + +Separate Markdown validation passed 90 link-check tasks. Final locked +cleanup proved **24 exact incarnations exited**, **3 ports closed**, zero +worktree processes and no shared/fixture claims. Four test workers briefly +outlived the failed aggregate but all exited naturally before cleanup +inspection. The final verifier and lock reacquisition passed. The harness +ledger preserves each exact command and every failed attempt. + +### 2026-11-12 review follow-up: bounded qualification fixtures + +Source commit `0fb6cd47fccc4f6210797b25085e7b56aa8ca4fe` bounds the +ready crash-owner fixture to a 120-second wait followed by exit 1 and adds +four lines requesting AppKit fixture termination on the main queue after +300 seconds. The `/bin/sleep 300` descendant was already bounded. Its focused +portable regression exercises the same wait with a short deadline and no +native resources. Production helper/ownership behavior, exact-identity +cleanup and durable claim fences are unchanged. + +The portable regression failed with `ETIMEDOUT` before the fix and passed +afterward. The focused harness suite passed 30 with no skips. Both owning +packages' format, lint and build passed; full portable suites passed driver +1,237 with 66 skipped and harness 410 with 14 skipped. + +**Native build passed, runtime blocked:** under the shared native lock, +`node packages/agentic/desktop-driver/native/macos/fixtures/build.cjs artifacts/desktop/f10m/retirement-followup-20261112/fixture` +compiled and strictly verified the stable-signed fixture. Nonprompting +doctor reported `screenLocked=true`, despite effective permission preflights. +No application was launched. The earlier native and Fabric/Paper passes are +pre-fix evidence present at `16558610ff2bd14775430f86c738c12947fe81ee`, +not qualification of the new retirement deadlines. + +The single fresh `yarn clean-lage-cache && yarn lage buildci` failed only the +unchanged `boundedCommand.test.ts:31` SIGKILL/SIGTERM assertion, with driver +1,236 passed, 1 failed, 66 skipped. No environment/concurrency override or +retry was used. Separate links passed 90 tasks. Final locked audits found +zero worktree workloads/unreadable candidates; no signals were issued. + +The harness ledger's "Review follow-up: bounded fixture retirement" section +records exact commands and limitations. Evidence is retained under +`artifacts/desktop/f10m/retirement-followup-20261112/`. + ## F15 non-AX macOS supporting evidence — 2026-11-12 Source `505a2187b0a2451aed7081c9260b343d55742f93`; macOS 27.2.1 diff --git a/packages/agentic/desktop-driver/src/host/types.ts b/packages/agentic/desktop-driver/src/host/types.ts index 107c77f395..b77e74fdbc 100644 --- a/packages/agentic/desktop-driver/src/host/types.ts +++ b/packages/agentic/desktop-driver/src/host/types.ts @@ -90,6 +90,8 @@ export type ApplicationLease = { ownership: 'attached' | 'launched'; processId?: number; processStartedAt?: string; + /** LaunchServices causality is not direct child-process containment. */ + launchKind?: 'launch-services' | 'direct-child'; }; export type DesktopWindow = { diff --git a/packages/agentic/desktop-driver/src/hosts/native/NativeDesktopHost.applicationIdentity.test.ts b/packages/agentic/desktop-driver/src/hosts/native/NativeDesktopHost.applicationIdentity.test.ts new file mode 100644 index 0000000000..5a25724d98 --- /dev/null +++ b/packages/agentic/desktop-driver/src/hosts/native/NativeDesktopHost.applicationIdentity.test.ts @@ -0,0 +1,138 @@ +import type { DesktopTarget } from '../../host/types'; +import type { NativeDriverArtifact } from '../../native/types'; +import { scriptNativeHelper } from '../../testing/scriptedNativeHelper'; +import { NativeDesktopHost } from './NativeDesktopHost'; + +const mockSpawn = jest.fn(); +jest.mock('node:child_process', () => ({ spawn: (...args: unknown[]) => mockSpawn(...args) })); + +const artifact: NativeDriverArtifact = { + architecture: 'arm64', + artifactId: 'artifact', + artifactRoot: 'artifact-root', + buildFingerprint: 'build', + buildId: 'build', + compatibilityKey: 'compatibility', + configuration: 'release', + endpoints: ['macos'], + executablePath: 'driver', + features: ['inspectApplication'], + origin: 'cache', + provider: 'macos', + schemaVersion: 1, + signing: { mode: 'none' }, + sourceDigest: 'source', + wireProtocol: { major: 1, minor: 0 }, +}; +const identity = { processId: 123, processStartedAt: '2026-11-12T00:00:00.123456Z' }; +const application = { leaseNonce: 'workload-nonce', leasePath: 'workload-lease.json', windowTitle: 'Storybook' }; +const registration = { endpoint: 'macos', id: 'storybook', platformName: 'macos' } satisfies Omit; + +function host() { + return new NativeDesktopHost({ application, artifact, endpoint: 'macos' }); +} + +beforeEach(() => { + mockSpawn.mockReset(); +}); + +test('identity inspection preserves the controlling session and still excludes a foreign helper', async () => { + let controller: number | undefined; + const requests = [0, 1].map((owner) => + scriptNativeHelper(mockSpawn, (request, send) => { + const respond = (result: unknown) => send({ id: request.id, result, type: 'response' }); + if (request.command === 'attach') { + if (controller !== undefined) { + send({ + error: { + code: 'attach-failed', + message: 'Another service controls this incarnation.', + data: { reason: 'application-lock-busy' }, + }, + id: request.id, + type: 'response', + }); + } else { + controller = owner; + respond({ ...identity, id: `lease-${owner}`, ownership: 'attached' }); + } + } else if (request.command === 'inspectApplication') { + respond(identity); + } else if (request.command === 'closeApplication') { + controller = undefined; + respond({}); + } else if (request.command === 'windows') { + respond([{ id: `window-${controller}`, title: application.windowTitle, rect: { x: 0, y: 0, width: 100, height: 100 } }]); + } + }), + ); + const owner = host(); + const foreign = host(); + try { + const lease = await owner.attach({ ...registration, host: owner }); + const before = await owner.windows(lease); + await expect(owner.inspectApplication({ ...registration, host: owner })).resolves.toEqual(identity); + // Observation from another helper is not a controlling attachment either. + await expect(foreign.inspectApplication({ ...registration, host: foreign })).resolves.toEqual(identity); + await expect(foreign.attach({ ...registration, host: foreign })).rejects.toMatchObject({ + code: 'attach-failed', + data: { reason: 'application-lock-busy' }, + }); + await expect(owner.windows(lease)).resolves.toEqual(before); + expect(requests[0].find((request) => request.command === 'inspectApplication')?.params).toMatchObject({ + application, + endpoint: 'macos', + targetId: registration.id, + }); + expect(requests[0].filter((request) => request.command === 'attach')).toHaveLength(1); + await owner.closeApplication(lease); + await expect(foreign.attach({ ...registration, host: foreign })).resolves.toMatchObject({ ...identity, ownership: 'attached' }); + } finally { + await Promise.all([owner.dispose(), foreign.dispose()]); + } +}); + +test.each([ + null, + { ...identity, processId: 0 }, + { ...identity, processId: 1.5 }, + { ...identity, processId: 0x80000000 }, + { ...identity, processStartedAt: '2026-11-12T00:00:00.123Z' }, + { ...identity, processStartedAt: '2026-99-12T00:00:00.123456Z' }, + { ...identity, id: 'unexpected-controlling-lease' }, +])('rejects an unverifiable identity response %j', async (result) => { + scriptNativeHelper(mockSpawn, (request, send) => send({ id: request.id, result, type: 'response' })); + const native = host(); + try { + await expect(native.inspectApplication({ ...registration, host: native })).rejects.toMatchObject({ + data: { nativeCode: 'invalid-application-identity' }, + }); + } finally { + await native.dispose(); + } +}); + +test('does not silently replace inspection with a controlling attach on an old helper', async () => { + const received = scriptNativeHelper(mockSpawn, (request, send) => + send({ error: { code: 'unsupported-operation', message: 'Unsupported command.' }, id: request.id, type: 'response' }), + ); + const native = host(); + try { + await expect(native.inspectApplication({ ...registration, host: native })).rejects.toMatchObject({ name: 'HostUnsupportedError' }); + expect(received.map((request) => request.command)).toEqual(['inspectApplication']); + } finally { + await native.dispose(); + } +}); + +test.each(['windows', 'win32'] as const)('reports inspection as unsupported on %s without spawning a helper', async (endpoint) => { + const native = new NativeDesktopHost({ + application, + artifact: { ...artifact, endpoints: ['windows', 'win32'], provider: 'windows' }, + endpoint, + }); + await expect(native.inspectApplication({ ...registration, endpoint, host: native })).rejects.toMatchObject({ + name: 'HostUnsupportedError', + }); + expect(mockSpawn).not.toHaveBeenCalled(); +}); diff --git a/packages/agentic/desktop-driver/src/hosts/native/NativeDesktopHost.ts b/packages/agentic/desktop-driver/src/hosts/native/NativeDesktopHost.ts index 0cede1771d..1be3b78afd 100644 --- a/packages/agentic/desktop-driver/src/hosts/native/NativeDesktopHost.ts +++ b/packages/agentic/desktop-driver/src/hosts/native/NativeDesktopHost.ts @@ -21,6 +21,7 @@ import type { Rect, } from '../../host/types.js'; import type { + NativeApplicationIdentity, NativeDesktopApplicationDescriptor, NativeDriverArtifact, NativeDriverProvider, @@ -120,6 +121,29 @@ export class NativeDesktopHost implements DesktopHost { return this.request('attach', this.targetParams(target), signal); } + /** macOS descriptor verification without acquiring a lease, application lock, or window/element records. */ + async inspectApplication(target: DesktopTarget, signal?: AbortSignal): Promise { + if (this.endpoint !== 'macos') throw new HostUnsupportedError('Read-only application identity inspection is macOS-only.'); + const result = await this.request | null>('inspectApplication', this.targetParams(target), signal); + if ( + !result || + typeof result !== 'object' || + Object.keys(result).length !== 2 || + typeof result.processId !== 'number' || + !Number.isInteger(result.processId) || + result.processId < 1 || + result.processId > 0x7fffffff || + typeof result.processStartedAt !== 'string' || + !/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{6}Z$/.test(result.processStartedAt) || + !Number.isFinite(Date.parse(result.processStartedAt)) + ) { + throw new HostWebDriverError('unknown error', 'The native helper returned an invalid application identity.', { + nativeCode: 'invalid-application-identity', + }); + } + return Object.freeze({ processId: result.processId, processStartedAt: result.processStartedAt }); + } + async closeApplication(lease: ApplicationLease, signal?: AbortSignal): Promise { await this.request('closeApplication', { lease }, signal); } diff --git a/packages/agentic/desktop-driver/src/hosts/native/NativeHostProcess.test.ts b/packages/agentic/desktop-driver/src/hosts/native/NativeHostProcess.test.ts index 6a045f918f..744ebb30ef 100644 --- a/packages/agentic/desktop-driver/src/hosts/native/NativeHostProcess.test.ts +++ b/packages/agentic/desktop-driver/src/hosts/native/NativeHostProcess.test.ts @@ -109,6 +109,41 @@ describe('NativeHostProcess mutation effects', () => { await host.dispose(); }); + test('preserves application settlement evidence from a cancelled launch', async () => { + const data = { processId: 123, processStartedAt: '2026-11-12T00:00:00.123456Z', applicationSettlement: 'unknown' } as const; + const { host } = await startHost(() => undefined, { + onCancel: (message, send) => send({ data, id: message.id, type: 'cancelled' }), + }); + const controller = new AbortController(); + const request = host.request('launch', {}, controller.signal); + controller.abort(); + expect(await failureOf(request)).toMatchObject({ name: 'AbortError', data }); + await host.dispose(); + }); + + test('a lost dispatched launch response retains uncertainty even without a process identity', async () => { + const { host } = await startHost((_message, _send, control) => control.exit(1)); + const failure = await failureOf(host.request('launch', {})); + expect(failure).toMatchObject({ + code: 'host-exited', + data: { applicationSettlement: 'unknown' }, + cause: expect.objectContaining({ code: 'host-exited' }), + }); + await host.dispose(); + }); + + test.each(['not-started', 'exited'] as const)('preserves a %s launch rejection without making it uncertain', async (settlement) => { + const { host } = await startHost((message, send) => + send({ + type: 'response', + id: message.id, + error: { code: 'launch-failed', message: 'Rejected launch.', data: { applicationSettlement: settlement } }, + }), + ); + expect(await failureOf(host.request('launch', {}))).toMatchObject({ data: { applicationSettlement: settlement } }); + await host.dispose(); + }); + test('treats a command that completes after its cancellation as applied', async () => { const { host } = await startHost(() => undefined, { onCancel: (message, send) => send({ id: message.id, result: {}, type: 'response' }), diff --git a/packages/agentic/desktop-driver/src/hosts/native/NativeHostProcess.ts b/packages/agentic/desktop-driver/src/hosts/native/NativeHostProcess.ts index 8c88217b1a..b978adc941 100644 --- a/packages/agentic/desktop-driver/src/hosts/native/NativeHostProcess.ts +++ b/packages/agentic/desktop-driver/src/hosts/native/NativeHostProcess.ts @@ -37,6 +37,7 @@ export type NativeHostRequestResult = { type PendingRequest = { aborted: boolean; abortCleanup: () => void; + command: string; binary?: Uint8Array; binaryId?: string; reject(error: unknown): void; @@ -265,6 +266,7 @@ export class NativeHostProcess extends EventEmitter<{ const pending: PendingRequest = { aborted: false, abortCleanup: () => undefined, + command, reject, resolve: resolve as (value: NativeHostRequestResult) => void, }; @@ -334,6 +336,7 @@ export class NativeHostProcess extends EventEmitter<{ if (message.type === 'cancelled') { const error = new Error(`Native driver command "${message.id}" was cancelled.`); error.name = 'AbortError'; + if (message.data) Object.assign(error, { data: message.data }); // A provider that does not report what the cancelled command left behind cannot rule out an effect. this.rejectPending(message.id, attachFailureEffect(error, parseMutationEffect(message.effect) ?? 'unknown')); return; @@ -537,8 +540,17 @@ export class NativeHostProcess extends EventEmitter<{ } } this.lifecycle = 'closed'; - for (const [id] of this.pending) { - this.rejectPending(id, failure); + for (const [id, pending] of this.pending) { + if (pending.command === 'launch') { + const launchFailure = new NativeDriverError(failure instanceof NativeDriverError ? failure.code : 'host-failed', failure.message, { + ...(failure instanceof NativeDriverError ? failure.data : undefined), + applicationSettlement: 'unknown', + }); + launchFailure.cause = failure; + this.rejectPending(id, launchFailure); + } else { + this.rejectPending(id, failure); + } } if (this.helloMessage) { try { diff --git a/packages/agentic/desktop-driver/src/index.ts b/packages/agentic/desktop-driver/src/index.ts index 7dc58ae333..fdecf7c447 100644 --- a/packages/agentic/desktop-driver/src/index.ts +++ b/packages/agentic/desktop-driver/src/index.ts @@ -129,6 +129,7 @@ export { verifyNativeDriverArtifact, } from './native/index.js'; export type { + NativeApplicationIdentity, NativeDesktopApplicationDescriptor, NativeDriverArchitecture, NativeDriverArtifact, diff --git a/packages/agentic/desktop-driver/src/native/index.ts b/packages/agentic/desktop-driver/src/native/index.ts index e5d3ea25f8..999f7a1ece 100644 --- a/packages/agentic/desktop-driver/src/native/index.ts +++ b/packages/agentic/desktop-driver/src/native/index.ts @@ -9,6 +9,7 @@ export { export { nativeDriverWireProtocol } from './constants.js'; export { NativeDriverError } from './NativeDriverError.js'; export type { + NativeApplicationIdentity, NativeDesktopApplicationDescriptor, NativeDriverArchitecture, NativeDriverArtifact, diff --git a/packages/agentic/desktop-driver/src/native/types.ts b/packages/agentic/desktop-driver/src/native/types.ts index 3a9789f5e9..74d1f8609d 100644 --- a/packages/agentic/desktop-driver/src/native/types.ts +++ b/packages/agentic/desktop-driver/src/native/types.ts @@ -55,6 +55,9 @@ export type NativeDriverResolveOptions = NativeDriverBuildOptions & { installRoot?: string; }; +/** Native-verified process identity without a controlling application lease. */ +export type NativeApplicationIdentity = Readonly<{ processId: number; processStartedAt: string }>; + export type NativeDesktopApplicationDescriptor = { arguments?: readonly string[]; aumid?: string; @@ -62,6 +65,8 @@ export type NativeDesktopApplicationDescriptor = { executablePath?: string; leaseNonce?: string; leasePath?: string; + /** Exact macOS attach identity. Never grants launch/termination authority and cannot be combined with a legacy lease. */ + processIdentity?: NativeApplicationIdentity; windowTitle?: string; workingDirectory?: string; }; @@ -84,6 +89,13 @@ export type NativeHostRequest = { type: 'request'; }; +export type NativeApplicationSettlement = 'not-started' | 'exited' | 'unknown'; + +type NativeHostFailureData = Record & { + /** Launch acquisition settlement, distinct from an input mutation effect. */ + applicationSettlement?: NativeApplicationSettlement; +}; + export type NativeHostResponse = { binary?: { height?: number; @@ -94,7 +106,7 @@ export type NativeHostResponse = { }; error?: { code: string; - data?: Record; + data?: NativeHostFailureData; /** What a failed mutating command left behind. Absent means the provider cannot rule an effect out. */ effect?: MutationEffect; message: string; @@ -119,6 +131,8 @@ export type NativeHostCancel = { export type NativeHostCancelled = { /** What the cancelled mutating command left behind. Absent means the provider cannot rule an effect out. */ effect?: MutationEffect; + /** Provider lifecycle settlement evidence, including an unleased application that a cancelled launch created. */ + data?: NativeHostFailureData; id: string; type: 'cancelled'; }; diff --git a/packages/agentic/desktop-test/README.md b/packages/agentic/desktop-test/README.md index b6852feb33..39f2c4c6f6 100644 --- a/packages/agentic/desktop-test/README.md +++ b/packages/agentic/desktop-test/README.md @@ -65,10 +65,12 @@ when working on the package. `test --attach-agent`; see [runner ownership](references/wdio-suites.md#reuse-an-attached-agent-service). Per-worker credentials are scrubbed before user hooks/specs; Node bootstrap and preloads remain trusted during the initial fork-environment window. - Storybook handoff and retained session launches remain unsupported. -- The generic harness supervises services, workers, and the driver; session-owned - app launch and the Storybook lifecycle takeover are transitional (see - [lifecycle](references/lifecycle.md)). +- The generic harness supervises services, workers, the driver, and application + claim resources. Launch defaults to session-owned; macOS can opt into + supervisor-owned launch. External attach always preserves the app. Neutral + exact identity helpers live at `/application-ownership`; Storybook handoff and + lifecycle takeover remain transitional, and retained session launches remain + unsupported (see [lifecycle](references/lifecycle.md)). - Every run records structured results, command output, and evidence in its artifact directory. - Native helpers are explicitly built or provisioned; normal test runs use diff --git a/packages/agentic/desktop-test/fixtures/application-ownership/crash-owner.mjs b/packages/agentic/desktop-test/fixtures/application-ownership/crash-owner.mjs new file mode 100644 index 0000000000..f693969308 --- /dev/null +++ b/packages/agentic/desktop-test/fixtures/application-ownership/crash-owner.mjs @@ -0,0 +1,43 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { NativeDesktopHost } from '@fluentui-react-native/desktop-driver'; +import { createDesktopApplicationHost } from '../../lib/applicationOwnership.js'; +import { createDesktopWorkloadClaim, describeDesktopWorkloadOwner } from '../../lib/ownership.js'; + +function waitForRetirement(timeoutMilliseconds) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, timeoutMilliseconds); + process.stderr.write('The native crash-owner retirement deadline expired.\n'); + process.exit(1); +} + +const [configurationFile, phase] = process.argv.slice(2); +if (configurationFile === '--retirement-probe') waitForRetirement(25); +const { artifact, application, directory } = JSON.parse(fs.readFileSync(configurationFile, 'utf8')); +if (phase !== 'pending' && phase !== 'acquired') throw new Error('Unknown publication interruption phase.'); +const processIdentity = await describeDesktopWorkloadOwner({ helperPath: artifact.executablePath }); +if (!processIdentity) throw new Error('The fixture owner requires verified OS identity.'); +const claim = createDesktopWorkloadClaim({ + directory, + runId: phase, + targetId: 'crash-fixture', + targetInstanceId: path.basename(directory), + processIdentity, +}); +const native = new NativeDesktopHost({ artifact, application, endpoint: 'macos' }); +await createDesktopApplicationHost(native, { + target: { endpoint: 'macos', id: 'crash-fixture', platformName: 'macos', launchTimeoutMs: 15_000 }, + lifetime: 'supervisor', + resourcePrefix: phase, + onResource(resource) { + if (resource.readiness !== 'ready' || phase === 'acquired') claim.updateResource(resource); + if (resource.readiness === 'ready') { + fs.writeFileSync(path.join(directory, 'acquired.json'), JSON.stringify({ owner: processIdentity, resource }), { + flag: 'wx', + mode: 0o600, + }); + // The parent kills this exact owner while publication or startup is interrupted. + waitForRetirement(120_000); + } + }, +}); diff --git a/packages/agentic/desktop-test/fixtures/neutral-boundary/standalone-consumer.mjs b/packages/agentic/desktop-test/fixtures/neutral-boundary/standalone-consumer.mjs index c2015a7961..dc925f1098 100644 --- a/packages/agentic/desktop-test/fixtures/neutral-boundary/standalone-consumer.mjs +++ b/packages/agentic/desktop-test/fixtures/neutral-boundary/standalone-consumer.mjs @@ -19,6 +19,7 @@ const entries = { './process-control': await import('@fluentui-react-native/desktop-test/process-control'), './resources': await import('@fluentui-react-native/desktop-test/resources'), './ownership': await import('@fluentui-react-native/desktop-test/ownership'), + './application-ownership': await import('@fluentui-react-native/desktop-test/application-ownership'), './jasmine': await import('@fluentui-react-native/desktop-test/jasmine'), './wdio': await import('@fluentui-react-native/desktop-test/wdio'), }; diff --git a/packages/agentic/desktop-test/package.json b/packages/agentic/desktop-test/package.json index 5176181dce..11ff423dbe 100644 --- a/packages/agentic/desktop-test/package.json +++ b/packages/agentic/desktop-test/package.json @@ -70,6 +70,11 @@ "import": "./lib/ownership.js", "default": "./src/ownership.ts" }, + "./application-ownership": { + "types": "./lib/applicationOwnership.d.ts", + "import": "./lib/applicationOwnership.js", + "default": "./src/applicationOwnership.ts" + }, "./jasmine": { "types": "./lib/jasmine/index.d.ts", "import": "./lib/jasmine/index.js", diff --git a/packages/agentic/desktop-test/references/configuration.md b/packages/agentic/desktop-test/references/configuration.md index ea2c40165c..b3ae796f0d 100644 --- a/packages/agentic/desktop-test/references/configuration.md +++ b/packages/agentic/desktop-test/references/configuration.md @@ -20,6 +20,20 @@ application identity. A Windows installed target requires an exact title and an executable path, AUMID, or verified owner lease; a title alone is not an identity. External ownership permits attach only and preserves the app. +`ownership: 'session'` (the default) keeps session-owned launch semantics. +`ownership: 'supervisor'` opts into one supervisor-owned macOS launch; successive +sessions borrow the same application, and scope disposal closes its exact native +incarnation. This mode is rejected on Windows/Win32 until their backend is +qualified. + +For neutral macOS attach, set `ownership: 'external'` and an application +`processIdentity: { processId, processStartedAt }` plus exact `windowTitle`. +Use an identity captured from a native lease through the +`/application-ownership` API: the timestamp must retain all six kernel +fractional digits. Bundle/executable constraints are additional checks, not a +replacement for the identity. Exact identity cannot be combined with a legacy +`leasePath`/`leaseNonce`; Storybook's existing lease remains separate. + `commands` overrides individual `prep`, `bundle`, `build`, or `run` stages. Each command has an executable and argument array; no shell-like string parsing is performed. Arguments and environment entries must not contain NUL, which no diff --git a/packages/agentic/desktop-test/references/current-status.md b/packages/agentic/desktop-test/references/current-status.md index c86021cf11..1e62eb8fd0 100644 --- a/packages/agentic/desktop-test/references/current-status.md +++ b/packages/agentic/desktop-test/references/current-status.md @@ -21,18 +21,18 @@ and its sub-issues. | -------------------------------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | App/suite configuration and standalone installed-app use | Done | Storybook is optional; runtime boundary and standalone-consumer contracts reject React, React Native, and Storybook imports/dependencies. Executable config is trusted operator code, not a sandbox. | | Native service and helper composition | Done | Generic supervisor and supervised Storybook child use the same real `driverService` factory, helper policy, authority and settled close. | -| Standalone WDIO/Jasmine execution | Done | WDIO owns its session; generic external-app `--attach-agent` reuses the service's claim/listener with a parent-owned reservation and drain-confirmed worker children. Real WDIO deterministic handoff/crash/lost-response contracts pass. Storybook CSF callbacks retain their runner-owned borrowed-session exception. Native endpoint qualification remains separate. | +| Standalone WDIO/Jasmine execution | Done | WDIO owns its session. Storybook CSF callbacks use a runner-owned session borrowed by a Node worker. | | Required services, reporting, structured results | Done | One collector; bounded JSON/NDJSON summaries. Service-ready is not app/context-ready. | -| `serve --agent` descriptor publication | Done | Protects driver routes; no automatic build/launch from MCP. Generic external-app services publish separate private agent and runner descriptors; unauthenticated WDIO remains refused. Runner preemption authority requires explicit operator opt-in. | +| `serve --agent` descriptor publication | Done | Protects driver routes; no automatic build/launch from MCP. Normal WDIO cannot use the agent-served path yet. | | Cleanup failure fence | Done, conservative | Failed or timed-out disposal retains the claim with an exact resource generation and unresolved effect; manual inspection is required. No automatic reclaim. | | Complete lifecycle settlement | Done, conservative | Guarded work settles before successful close; late acquisitions are disposed; required-service failures stop dependants; owned resources dispose in reverse order. | | Typed generic resource contributions | Done | Static contribution plans validate IDs/dependencies and record readiness, generation, identity, ownership, and settled disposal through public `resources` and `ownership` subpaths. | | Durable quarantine wiring | Done | Both native service paths use owner-only storage and exact-incarnation probes; restart is not recovery proof. Live endpoint qualification remains separate. | | Supervisor crash containment | Work in progress | Cooperative Node service lifelines and correlated service readiness implemented; portable parent/wrapper crash, late-spawn, failed-withdrawal and stalled-child contracts. Live macOS survivors remain unqualified; no automatic reclaim. Windows: the helper's Job Object ends an unpackaged app it launched when the helper dies (qualified with REX); packaged launches are `uncontained` and survive in a fenced claim. Non-detached children are hard-terminated with a crashed owner (observed A/B), so the three crash-disposal contracts are skipped there. | | Storybook resource contributions | Work in progress | Current integration substitutes its CLI lifecycle; retain the channel/driver child until relocation is justified and qualified. | -| MCP/WDIO handoff | Work in progress | Generic attach-only phase implemented and qualified on real macOS AppKit and Windows Win32 fixtures: bounded busy, relinquish/operator preemption, settled deletion, exclusive reservation, worker-crash/lost-response drain, fresh resources and credential confinement. See the dated F11 receipts below. Storybook handoff still waits for F9; retained launches require F10 and explicit ownership. | -| Supervisor-owned app lifetime | Work in progress | Current session-owned launch/externally owned attach semantics remain; never silently retain or terminate another owner's app. | -| Cross-service/multi-target claims | Work in progress | Configured aliases now have distinct registered IDs while retaining physical identity; exact-application exclusion and input-mutex qualification remain open. | +| MCP/WDIO handoff | Work in progress | Generic attach-only phase is implemented and qualified on real macOS AppKit and Windows/Win32 fixtures: bounded busy, operator preemption, settled deletion, exclusive reservation, worker-crash/lost-response drain, fresh resources, and credential confinement. Storybook handoff remains deferred; see the dated F11 receipts below. | +| Supervisor-owned app lifetime | Work in progress | Opt-in macOS supervisor launch, neutral exact attach, causal ownership and application claim resources are implemented. Session-owned launch remains the default; external attach never transfers ownership. Signed AppKit supervisor/session lifetime, exact cleanup and external preservation passed on 2026-11-12. Renderer evidence is scoped below; descendants, interrupted native publication and Storybook lifecycle relocation remain gates. | +| Cross-service/multi-target claims | Work in progress | Configured aliases have distinct registered IDs while retaining physical identity. Exact macOS application exclusion, lease release and the physical-input mutex passed signed AppKit contracts on 2026-11-12; cross-platform and broader lifecycle qualification remains open. | | Bounded persisted run/status/artifact queries | Done | Harness-local read service/client; atomic snapshots, terminal evidence, scoped expiring grants, bounded pages and allowlisted opaque artifact IDs. No management transport. | | Workload management/control API | Work in progress | Future management selects only declared workload IDs, not commands/paths/environment; lifecycle/handoff gates still apply. | | Automatic service reuse and per-user daemon | Deferred | Requires exact identity/config matching and evidence of need. Never adopt an unknown port occupant. | @@ -64,14 +64,14 @@ did not retain a portable raw-report locator; do not imply otherwise. ## Full-submission checklist -| Area | Remaining evidence/work | -| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| macOS | Live failed-cleanup and service-restart cases; Paper separately; supervisor death with Metro/channel/helper survivors; exact attached-app preservation. | -| Windows / Win32 | Same-branch MCP restart/revocation/dispose/args tests; physical console Ctrl+C requalification of `serve --agent` after the idle-CLI fix; interactive input/capture rerun; naming launched (especially uncontained packaged) apps in the claim. | -| Storybook | Authenticated mutation ingress, context fencing/reconnect, consistent helper build policy. F6 strict provenance and independent nonces are qualified by same-branch Windows Fabric and both Win32 Paper lanes in run 43156299266; see the F6 evidence below for scope and limits. | -| Recovery | Durable uncertainty is wired in both paths; live restart qualification, ledger and exact-resource reconciliation still precede automatic reclaim. The workload claim records a Node.js-clock start time, so it needs a helper-described start time before claim inspection can prove a live owner or a reused PID. | -| Concurrency | No physical-input suites in parallel on one interactive desktop. Separate machines may qualify independent endpoints. | -| Reliability | Establish the required repeated-run window rather than interpreting aggregate past successes as a pass. | +| Area | Remaining evidence/work | +| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| macOS | Signed AppKit and separate Fabric/Paper positive lifecycle, inspection, exclusion and exact primary cleanup passed on 2026-11-12. Rebased native contracts also pass interrupted acquisition publication, owner death with native app/known-descendant survivors, retained claim fences and exact operator cleanup. Arbitrary descendants, Metro/channel survivors, automatic recovery and repeated-run reliability remain unqualified. | +| Windows / Win32 | Same-branch MCP restart/revocation/dispose/args tests; physical console Ctrl+C requalification of `serve --agent`; interactive input/capture rerun. Generic launched-application claim fields and durable survivor contracts are implemented; live uncontained packaged-survivor journaling/recovery remains not run for this change. | +| Storybook | Authenticated mutation ingress, context fencing/reconnect, consistent helper build policy. F6 strict provenance and independent nonces are qualified by same-branch Windows Fabric and both Win32 Paper lanes in run 43156299266; see the F6 evidence below for scope and limits. | +| Recovery | Durable uncertainty is wired in both paths; live restart qualification, ledger and exact-resource reconciliation still precede automatic reclaim. Claim schema 3 records the driver's OS start identity; schema 2 and unverified claims inspect as unknown. LIFE-8's clock-estimate mismatch is fixed; LIFE-9's existing stable per-user claim root has a TMPDIR regression contract. Automatic reclaim remains unsupported. | +| Concurrency | No physical-input suites in parallel on one interactive desktop. Separate machines may qualify independent endpoints. | +| Reliability | Establish the required repeated-run window rather than interpreting aggregate past successes as a pass. | Tracking: live macOS, Windows, and Win32 requalification [#4441](https://github.com/microsoft/fluentui-react-native/issues/4441); @@ -1027,6 +1027,902 @@ repeated. `pwsh` is unavailable locally, so its parser check remains not run; the actual scripts executed in these CI lanes. Keep the PR draft until the documentation follow-up's CI passes and the coordinator changes its status. +## 2026-11-12: F10-M neutral application ownership + +Implementation source: +`76a2be59abfde3a7bcb0abd8d86a2052e7378274`. +Host: macOS 27.2.1, arm64, isolated signed AppKit fixture; no React renderer. +The helper used the stable `FURN Desktop Driver Development` identity, +artifact `73d2e52b2ad9fbd51410301399f3b784a2d17ae8c2b34ebb12b36cd9b41626a1`, +source digest `b0e40122cc2226ffa65486677af7ba2495e1138cbc2c7e70af22fd36671e796f`. +Evidence records the exact source revision and the three documentation-only +working-tree changes present during the run. + +The neutral `application-ownership` public entry provides exact macOS attach +identity and opt-in supervisor-owned launch; session launch stays the default. +Attached apps remain external. Both macOS launch kinds are explicitly +uncontained: LaunchServices causal ownership is not direct-child containment. +Pending and acquired applications are claim resources, including exact +PID/start and launch/containment metadata when available. Unknown publication +or cleanup outcomes remain fenced. `claimDesktopWorkload` is now asynchronous; +schema 3 uses the driver's OS start-time identity, and schema 2/unverified +identities inspect as unknown. Existing Storybook lease migration remains +deferred until after F9. + +All commands below ran under the exclusive native-validation lock with +project-local ignored `TMPDIR`; native commands additionally used +`FURN_NATIVE_DRIVER_TEST=1`, the stable signing identity, and an isolated helper +cache. Commands are shown from the repository root. + +| Selection | Exact command | Result | Workspace-relative artifact | +| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------ | -------------------------------------------------- | +| Full portable harness | `yarn workspace @fluentui-react-native/desktop-test test --runInBand` | Passed 399, failed 0, skipped 9; 25 suites passed, 1 skipped | `artifacts/desktop/f10m/harness-unit-final.log` | +| Native owner and rejected-launch safety | `yarn workspace @fluentui-react-native/desktop-test test --runInBand applicationOwnership.contract --testNamePattern 'kernel owner identity\|failed native'` | Passed 3, failed 0, skipped 5 | `artifacts/desktop/f10m/native-negative-final.log` | +| Native identity and application exclusion backend | `yarn workspace @fluentui-react-native/desktop-driver test --runInBand applicationIncarnation serviceIdentity` | Passed 69, failed 0, skipped 14; 4 suites passed, 2 skipped | `artifacts/desktop/f10m/native-identity-final.log` | + +The first full harness run had one missing compiled `desktop-driver-mcp` +boundary dependency. Building that unchanged workspace corrected the +prerequisite; all eight standalone-consumer boundary cases then passed, +including the new public entry, and the full harness rerun above passed. +Portable Windows/Win32 cases prove durable launched-app survivor records and +fail-closed claims only; they are not native packaged-survivor qualification. + +Native evidence: +`packages/agentic/desktop-test/artifacts/desktop/application-ownership-0fNQ8C/evidence.json`. +Schema 3 inspected its native OS owner as live. Both direct-child and +LaunchServices launches reached actual AX queries, which returned +`apiDisabled` (-25211), then journaled and settled their exact primary-process +identities. Direct child PID 8558 started at +`2026-11-12T02:37:23.794023Z`; LaunchServices PID 8616 started at +`2026-11-12T02:37:24.304837Z`. Native probes proved those incarnations exited; +an independent process check found both PIDs absent and no fixture/helper +process under this worktree. No external process was terminated. +Post-run verification is retained at +`artifacts/desktop/f10m/native-cleanup-final.json`. + +An earlier fixture inherited Mach-O minimum macOS 28.0 on this 27.2.1 host. +LaunchServices failed before publishing a PID +(`NSCocoaErrorDomain` 256, underlying `NSOSStatusErrorDomain` -10825); +that older implementation left the pending resource uncertain. Completed +no-application failures are settled by the review follow-up below. +The corrected fixture pins the helper's macOS 11.0 minimum in both compiler +target and bundle metadata; +`vtool` confirmed 11.0 and the corrected rejected-launch run passed above. +The earlier evidence is retained at +`packages/agentic/desktop-test/artifacts/desktop/application-ownership-UiVcfS/evidence.json`. + +**Remaining qualification:** positive supervisor/session lifecycle, exact +external-app preservation and independent-service application exclusion are +blocked: Accessibility is not granted to the signed helper. Physical-input +mutex qualification is not run; PostEvent preflight is also false. Separate +macOS Fabric and Paper signing/TCC-responsibility lanes are not run on this +branch because of that permission block. No privacy settings were changed. +Arbitrary surviving descendants, interrupted native publication and live +Windows/Win32 uncontained packaged-survivor claims remain gates. The PR's +Windows driver-only native suite and ordinary E2E/smoke jobs do not exercise +the new harness survivor contracts; Windows CI is insufficient for those +gates. This is partial F10-M delivery, not full issue completion. + +### Pre-review repository validation + +At source `48717724f0df1e8f89d76b66c48d37fac93e871b`, +`yarn clean-lage-cache` passed once, followed by the locked aggregate: + +```sh +TMPDIR="$PWD/artifacts/desktop/f10m/buildci-tmp" \ +GIT_CEILING_DIRECTORIES="$PWD/artifacts/desktop/f10m/buildci-tmp" \ +TF_BUILD=1 yarn lage buildci --concurrency 4 +``` + +Result: passed, 176 successful tasks, 2 cache skips, 0 pending/aborted/failed. +`TF_BUILD=1` selects the declared Jest task's in-band runner; the Git ceiling +keeps project-local temporary test fixtures from discovering the outer +repository. No baseline tests or dependencies were changed for this setup. +An initial aggregate stopped at two owned cross-package guidance links; +replacing them with explicit code paths fixed the policy violation, and +`yarn check-agent-instructions` then passed. The corrected aggregate above +passed, including all owning workspace test/lint tasks. Package format/build +checks passed separately. `yarn lage test-links --concurrency 4` passed 90 +tasks with no failures. Logs: `artifacts/desktop/f10m/buildci-first.log`, +`artifacts/desktop/f10m/buildci-final.log` and +`artifacts/desktop/f10m/test-links-final.log`. Aggregate success does not +qualify the blocked or unrun native gates above. + +### Review follow-up: launch rejection and lock-failure settlement + +Source `62e6552028ea8fb81e76d430379321145fab709c` fixes the three review +findings. Native pre-spawn rejection and completed LaunchServices failure with +no application report `applicationSettlement: 'not-started'`. Ordinary no-data +macOS/Windows/Win32 rejections release the claim; reported process identity, +unknown or unrecognized settlement, lost dispatched responses and completion +after cancellation remain fenced. An acquired lease is never cleared by the +no-data fallback. The async `claimDesktopWorkload` export and awaited Storybook +caller are unchanged; desktop-test now has a **minor** changeset. + +Default-TMPDIR focused tests used only project-local test files and Jest cache. +Native commands ran under the exclusive lock, with the stable signing identity, +native opt-in, an isolated helper cache and project-local temporary storage: + +| Selection | Exact command | Result | +| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------- | +| Default-TMPDIR ownership | `yarn workspace @fluentui-react-native/desktop-test test --runInBand applicationOwnership.test --cacheDirectory "$PWD/artifacts/desktop/f10m/jest-cache"` | Passed 24, failed 0, skipped 0 | +| Default-TMPDIR transport | `yarn workspace @fluentui-react-native/desktop-driver test --runInBand NativeHostProcess --cacheDirectory "$PWD/artifacts/desktop/f10m/jest-cache"` | Passed 59, failed 0, skipped 0 | +| Signed helper rebuild | `yarn workspace @fluentui-react-native/desktop-driver exec desktop-driver build-driver --platform macos --force` | Passed | +| Native identity and termination | `yarn workspace @fluentui-react-native/desktop-driver test --runInBand nativeDriver.test applicationIncarnation serviceIdentity` | Passed 79, failed 0, skipped 15; 5 suites passed, 2 skipped | +| AppKit safety | `yarn workspace @fluentui-react-native/desktop-test test --runInBand applicationOwnership.contract --testNamePattern 'kernel owner identity\|failed native'` | Passed 4, failed 0, skipped 5 | +| Portable CLI retry, native-only environment removed | `yarn workspace @fluentui-react-native/desktop-test test --runInBand contracts --testNamePattern 'agent-cli\|lane-cli'` | Passed 2, failed 0, skipped 28 unselected | +| Full portable harness, native-only environment removed | `yarn workspace @fluentui-react-native/desktop-test test --runInBand` | Passed 409, failed 0, skipped 10; 25 suites passed, 1 skipped | + +The native self-test distinguishes actual flock contention from other errors. +Injected EBADF cleans up a real causally owned child and proves its exact exit; +injected EWOULDBLOCK preserves its real child. The AppKit safety case proves +claim release/reacquisition for exact-identity launch refusal, a missing direct +executable, a missing installed bundle, and a completed LaunchServices rejection. +Both AX-rejected launch kinds still prove exact-primary cleanup. + +New evidence: +`packages/agentic/desktop-test/artifacts/desktop/application-ownership-JMEGuu/evidence.json`; +it records a clean source tree. Helper artifact: +`1d68fae8df504432900e30e5b2a6714029762f5dd690cd953a8ef07b1f5da4f3`; +source digest: +`2c8ac56b2b824b05b1cb10d358050dfb643b44eb6e209508d53eaa0f2744b0f8`. +Direct-child PID 70283/start `2026-11-12T03:14:29.097020Z` and +LaunchServices PID 70323/start `2026-11-12T03:14:29.588927Z` were proved exited. +Independent post-run inspection found both PIDs absent and no own-worktree +fixture/helper process. Logs: +`artifacts/desktop/f10m/review-focused-final.log`, +`artifacts/desktop/f10m/review-native-final.log` and +`artifacts/desktop/f10m/review-native-cleanup.json`. +An extra full portable run in the native batch initially passed 407, failed 2, +skipped 10 because the HOME-isolating CLI fixtures inherited native-only +signing/cache settings and could not resolve the configured signing identity. +Removing `FURN_NATIVE_DRIVER_TEST`, +`FURN_DESKTOP_DRIVER_MACOS_SIGNING_IDENTITY` and +`FURN_DESKTOP_DRIVER_CACHE_ROOT` corrected the invocation, without a source or +fixture change. Both affected cases and then the full portable suite passed +under the exclusive lock; log: +`artifacts/desktop/f10m/review-harness-final.log`. +These safety passes do not close any positive AX, physical-input, renderer, +descendant, interrupted-publication or Windows-native gate above. + +### Final review-fix repository validation + +At clean source `e74043b0e6eb5b452e9023bc60c70ba6c9540600`, one combined +clean-cache aggregate passed under the exclusive native-validation lock: + +```sh +env -u FURN_NATIVE_DRIVER_TEST \ + -u FURN_DESKTOP_DRIVER_MACOS_SIGNING_IDENTITY \ + -u FURN_DESKTOP_DRIVER_CACHE_ROOT \ + TMPDIR="$PWD/artifacts/desktop/f10m/review-buildci-tmp" \ + GIT_CEILING_DIRECTORIES="$PWD/artifacts/desktop/f10m/review-buildci-tmp" \ + TF_BUILD=1 \ + bash -c 'yarn clean-lage-cache && yarn lage buildci --concurrency 4' +``` + +Result: **passed, 178 successful tasks, 0 skipped, 0 pending/aborted/failed**. +All owning workspace aggregate tasks passed; native-only overrides were not +inherited. `TF_BUILD=1` retains the supported in-band Jest task, and the Git +ceiling isolates project-local temporary Git fixtures. No source, dependency or +test waiver was needed. Log: +`artifacts/desktop/f10m/review-buildci-final.log`. +The code remains the native-qualified `62e6552028ea8fb81e76d430379321145fab709c`; +subsequent changes record evidence only. Positive native acceptance gates remain +open, so delivery is draft and does not close #4446. + +### CI regression follow-up: read-only application identity + +CI run `43148275374` at `0bd0ad6f7cc1e862169a422b7614a95adf899584` +failed both macOS Storybook renderers with `application-lock-busy` while +recording the authenticated bridge epoch. The actual WebDriver session's +native helper held its legitimate exact-incarnation application lock. +Storybook's recovery callback ignored that helper and created another +`NativeDesktopHost` whose temporary `attach` requested competing control. +This was not a workload-claim leak or an ownership handoff. +The passing macOS E2E artifact reports 62 tests with session ownership: +`desktop-test/src/wdio/configuration.ts` selects `launch`, not the Storybook +attach/recovery path, and that lane has no Storybook bridge-epoch journal. + +Fix source `5e360734e9b85bd4732aabc47c7654bb7211c3d3` introduces the +macOS-only read-only `inspectApplication` operation. It shares descriptor, +legacy nonce/lease, executable/bundle and exact-window verification, capturing +the kernel identity before discovery and rechecking it afterwards. It does +not acquire control or register windows/elements. Storybook uses the existing +session helper; every real launch/attach retains exclusive exact-incarnation +control, and foreign controlling services still receive +`application-lock-busy`. Windows/Win32 keep their previous isolated identity +attachment path. Unsupported old helpers fail closed rather than falling +back to controlling macOS attachment. + +All three owning packages passed their declared format, build and lint checks. +Build/test/lint batches and native qualification used the exclusive +native-validation lock. Focused tests kept the default TMPDIR unchanged; +their fixtures and Jest caches are project-local. The full +suites instead used project-local TMPDIR and the matching Git ceiling because +existing broad fixtures use `os.tmpdir()` and this task forbids system +temporary-directory writes. This is a declared deviation from the requested +default-TMPDIR full-suite invocation, not default-TMPDIR full-suite evidence. + +| Selection | Exact command | Result | +| -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| Default-TMPDIR native adapter | `yarn workspace @fluentui-react-native/desktop-driver test --runInBand NativeDesktopHost.applicationIdentity --cacheDirectory "$PWD/artifacts/desktop/f10m/ci-regression/jest-driver"` | Passed 11, failed 0, skipped 0 | +| Default-TMPDIR composed Storybook regression | `yarn workspace @fluentui-react-native/storybook-desktop test --runInBand applicationIdentity --cacheDirectory "$PWD/artifacts/desktop/f10m/ci-regression/jest-storybook"` | Passed 5, failed 0, skipped 0 | +| Default-TMPDIR claim/ownership regressions | `yarn workspace @fluentui-react-native/desktop-test test --runInBand applicationOwnership.test ownership.test --cacheDirectory "$PWD/artifacts/desktop/f10m/ci-regression/jest-harness"` | Passed 29, failed 0, skipped 0 | +| Full portable driver | `yarn workspace @fluentui-react-native/desktop-driver test --runInBand --cacheDirectory "$PWD/artifacts/desktop/f10m/ci-regression/jest-desktop-driver-full"` | Passed 1,236, failed 0, skipped 66 | +| Full portable harness | `yarn workspace @fluentui-react-native/desktop-test test --runInBand --cacheDirectory "$PWD/artifacts/desktop/f10m/ci-regression/jest-desktop-test-full"` | Passed 409, failed 0, skipped 10 | +| Full portable Storybook | `yarn workspace @fluentui-react-native/storybook-desktop test --runInBand --cacheDirectory "$PWD/artifacts/desktop/f10m/ci-regression/jest-storybook-desktop-full"` | Passed 344, failed 0, skipped 4 | +| Stable-signed helper rebuild | `yarn workspace @fluentui-react-native/desktop-driver exec desktop-driver build-driver --platform macos --force` | Passed | +| Noninteractive permissions | `yarn workspace @fluentui-react-native/desktop-driver exec desktop-driver doctor --platform macos --permissions` | Passed diagnostics; Accessibility/PostEvent preflight false, Screen Recording true | +| Native identity/termination/backend | `yarn workspace @fluentui-react-native/desktop-driver test --runInBand nativeDriver.test applicationIncarnation serviceIdentity --cacheDirectory "$PWD/artifacts/desktop/f10m/ci-regression/jest-driver-native"` | Passed 79, failed 0, skipped 15 | +| AppKit safety | `yarn workspace @fluentui-react-native/desktop-test test --runInBand applicationOwnership.contract --testNamePattern 'kernel owner identity\|failed native' --cacheDirectory "$PWD/artifacts/desktop/f10m/ci-regression/jest-harness-native"` | Passed 4, failed 0, skipped 5 | + +The composed regression covers launch, application claim publication, +embedded-driver attach and authenticated epoch observation separately for +Fabric and Paper, with retained window identity and rejected foreign control. +The native self-test additionally refuses missing, malformed and stale +inspection descriptors before AX discovery. The positive AppKit contracts now +inspect an application while its controlling session and foreign helper are +present, but those AX-dependent cases remain locally blocked, not passed. + +The stable-signed native run used a clean source tree. Helper artifact: +`982f3cfa696bbde7f9868186ae9365d59014fd656687e5cbec2de74863280ed3`; +source digest: +`6bafaf5835d9cd22410992466a8d8f86086ff6b3b3efd182b3a48922f698527c`. +AppKit evidence: +`packages/agentic/desktop-test/artifacts/desktop/application-ownership-mwo1d2/evidence.json`. +Rejected direct-child PID 53320/start `2026-11-12T04:18:28.787757Z` and +LaunchServices PID 53360/start `2026-11-12T04:18:29.263568Z` were proved exited; +independent post-run inspection found both PIDs absent and no own-worktree +fixture/helper process. No privacy settings were changed. + +Logs and curated CI analysis are under +`artifacts/desktop/f10m/ci-regression/`: `focused-default-tmp.log`, +`package-validation.log`, `native-validation.log`, `native-cleanup.json` and +`ci-analysis.json`. Actual macOS Storybook Fabric/Paper CI requalification at +the fix is pending. Local positive AX/physical input, descendant, interrupted +publication and Windows-native acceptance gates above remain open; keep draft. + +#### Final CI-regression aggregate + +The first clean-cache aggregate at documentation revision +`a7c37b86abdfc3847824480300315cf88eebaf42` failed the Storybook test task +after 112 successful tasks. Its output stopped after 13 passing Jest files, +without an assertion, stack, summary or termination cause. The cause remains +unestablished. The unchanged full Storybook suite then passed 344 with 4 +skipped under the identical aggregate environment; that reproduction is not +a successful aggregate. + +A second clean-cache aggregate serialized package tasks to remove +inter-package fixture/process overlap. Under the exclusive native-validation +lock, the following invocation passed **178 tasks**, with 0 skipped, failed, +pending or aborted, in 6m 12.39s: + +```sh +env -u FURN_NATIVE_DRIVER_TEST \ + -u FURN_DESKTOP_DRIVER_MACOS_SIGNING_IDENTITY \ + -u FURN_DESKTOP_DRIVER_CACHE_ROOT \ + TMPDIR="$PWD/artifacts/desktop/f10m/ci-regression/buildci-tmp" \ + GIT_CEILING_DIRECTORIES="$PWD/artifacts/desktop/f10m/ci-regression/buildci-tmp" \ + TF_BUILD=1 \ + bash -c 'set -euo pipefail; git rev-parse HEAD; git status --short; yarn clean-lage-cache && yarn lage buildci --concurrency 1' +``` + +No source or test waiver was applied between these runs. Serialization is +the retry's isolation measure, not a proven diagnosis of the first failure. +Logs: `artifacts/desktop/f10m/ci-regression/buildci.log`, +`artifacts/desktop/f10m/ci-regression/storybook-buildci-environment.log` +and `artifacts/desktop/f10m/ci-regression/buildci-final.log`. +Runtime source remains the native-qualified `5e360734e`; subsequent changes +record evidence only. This is portable repository validation, not positive +AX or renderer qualification. Actual macOS Storybook Fabric/Paper CI and the +other native acceptance gates remain pending; keep draft. + +### 2026-11-12 AX-enabled F10-M qualification + +**Observed.** This evidence supersedes the earlier Accessibility/PostEvent +block, not the remaining descendant/publication gates. Source is +`9395737943d7e1402a40a67a41dc605ef0afb24b` plus this delivery's live +macOS discovery fix. Host: macOS 27.2.1 arm64. Feasibility was recorded before +execution at +`artifacts/desktop/f10m/ax-qualification-20261112/feasibility.json`. +Every native, GUI, physical-input and aggregate command used +`$HOME/Library/Caches/furn-overnight/with-native-lock.sh`. +The default TMPDIR was retained for this requalification; the older +project-local-TMPDIR qualification remains historical. + +The requested diagnostic passed: + +```sh +env FURN_DESKTOP_DRIVER_MACOS_SIGNING_IDENTITY="FURN Desktop Driver Development" \ + FURN_DESKTOP_DRIVER_CACHE_ROOT="$PWD/artifacts/desktop/f10m/helper" \ + FURN_DESKTOP_DRIVER_BUILD_POLICY=never \ + yarn workspace @fluentui-react-native/desktop-driver exec desktop-driver \ + --reporter json doctor --platform macos --permissions +``` + +Accessibility, PostEvent and ScreenCapture preflight were `true`; the screen +was unlocked/on-console and `promptRequested=false`. Stable signed helper: +`1429ce4663795bad31f545086ef80bffb83e9b9533287964991c5527388b2f6c`; +source digest +`c17944691081d30a320cf6e4393a5bb0ce6cece8dd71295a400dbc5e6a53b3cb`. +The certificate leaf and designated requirement match the driver's evidence +above. Process ancestry reaches `Rex Server.app`; the effective permission +result is scoped to that responsible process tree. **Inferred, not qualified:** +these passes do not establish that the helper bundle independently owns a +durable grant or that another terminal/CI process tree inherits it. + +The initial full AppKit run passed 8 and failed 1. Its newly launched app was +missing from the standalone helper's cached `NSWorkspace` application list. +The new warmed-discovery regression failed before the native fix (1 failed, +9 skipped). Live descriptor discovery now uses current-user kernel PID +enumeration with fresh `NSRunningApplication` objects; exact identity, +ambiguity refusal and launch causality remain enforced. The final focused run +passed **39, failed 0, skipped 0**: + +```sh +env FURN_NATIVE_DRIVER_TEST=1 FURN_NATIVE_PHYSICAL_INPUT_TEST=1 \ + FURN_DESKTOP_DRIVER_MACOS_SIGNING_IDENTITY="FURN Desktop Driver Development" \ + FURN_DESKTOP_DRIVER_CACHE_ROOT="$PWD/artifacts/desktop/f10m/helper" \ + yarn workspace @fluentui-react-native/desktop-test test --runInBand \ + applicationOwnership.contract applicationOwnership.test ownership.test \ + --cacheDirectory "$PWD/artifacts/desktop/f10m/ax-qualification-20261112/jest-native-harness" +``` + +Ten native tests cover positive supervisor/session lifecycle, exact OS owner +identity, external preservation, independent-service application exclusion +and lease release, read-only observation with retained windows, warmed +discovery and duplicate-bundle refusal, stale identity, direct-child and +LaunchServices causality, cancellation, rejected launch cleanup, and the +physical-input mutex. The remaining 29 are portable regressions. Physical +input was limited to the mutex's Shift down/up sequence. + +During implementation, two experimental LaunchServices attempts could not +publish causal ownership. They were not silently treated as owned or exited. +The run-specific executable paths, native start identities and launch-time +intervals were checked before exact operator cleanup; native probes then +reported both exited (PIDs 63263 and 65041). The experimental change to launch +preexistence discovery was reverted; only descriptor discovery is changed. +Evidence: +`orphan-native-launch-cleanup.json`, `orphan-physical-launch-cleanup.json`, +the red/experimental/green logs and `discovery-probe.json` under +`artifacts/desktop/f10m/ax-qualification-20261112/`. +Final passing AppKit identities and cleanup: +`packages/agentic/desktop-test/artifacts/desktop/application-ownership-1V0SrR/evidence.json`. + +#### Real Storybook renderer procedure and boundaries + +The ignored qualifier +`artifacts/desktop/f10m/ax-qualification-20261112/qualify-renderer.mjs` +uses the declared app commands below from `apps/storybook`. Fabric omits +`--paper`; Paper adds it to every renderer-specific command. Preparation is +rerun when changing renderer: + +```sh +yarn storybook --reporter json instance --macos +yarn storybook --reporter json bundle --macos +yarn storybook --reporter json prep --macos +yarn storybook --reporter json manifest --macos +yarn storybook --reporter json driver --macos --input accessible +yarn storybook --reporter json run --macos +STORYBOOK_SMOKE_STORY=components-button--default \ + yarn storybook --reporter json smoke --macos --mode stories-and-tests --input accessible +``` + +Only `run` applies the worktree instance bundle ID. Before restarting the +driver, the qualifier quits the exact app it launched and closes the old +driver/Metro/channel. Its observer is started before app launch to exercise +warmed discovery. Startup polling retries only read-only missing/incomplete +AX discovery, never launch, attach or another mutation. + +The real-app checks use public `DesktopSupervisor`, native-host, identity, +claim-inspection and driver-client APIs. Two independent services have +separate claim directories and native helpers. The first attaches; both +helpers can inspect its exact app identity without changing window handles. +The second's native controlling attach returns `application-lock-busy`, and +its WebDriver new-session request is refused. Releasing the first session +lets the second attach. Deleting sessions and closing both external owners +preserves the app. A separate supervisor-owned launch then survives two +borrowed sessions, publishes its exact owned application resource and exits +only at supervisor disposal. Each neutral claim verifies schema 3, native +owner start identity, registered target and target-instance IDs; its claim is +absent after settled close. + +**Observed integration limit:** Storybook's outer CLI claim still uses +`unregistered` target/instance placeholders and no application resource +journal. Its OS owner identity and run ID are verified separately. This +qualification does not pretend that the deferred Storybook lifecycle/lease +relocation after F9 has happened; the exact registered application-resource +assertions are on the neutral supervisor claims. + +The first Fabric prep attempt failed before any app/service launch because +the installed Ruby 4.0.6 selected JSON 3.1.0, which lacks the `fast_generate` +entry used by this CocoaPods toolchain. Selecting the already-installed +JSON 2.18.0 through a project-local Ruby preload fixed prep. The preload +contains only `gem 'json', '=2.18.0'`; no gem installation, generated-source +patch, Podfile change or global Ruby configuration change was made. +Renderer invocations use: + +```sh +env RUBYOPT="-r$PWD/artifacts/desktop/f10m/ax-qualification-20261112/ruby-json-version.rb" \ + FURN_DESKTOP_DRIVER_MACOS_SIGNING_IDENTITY="FURN Desktop Driver Development" \ + FURN_DESKTOP_DRIVER_CACHE_ROOT="$PWD/artifacts/desktop/f10m/helper" \ + FURN_DESKTOP_DRIVER_BUILD_POLICY=never \ + node artifacts/desktop/f10m/ax-qualification-20261112/qualify-renderer.mjs fabric +``` + +For Paper, the final argument is `paper`. The machine-wide native-lock +wrapper encloses each complete invocation, including cleanup. The qualifier +selects Button/Default by manifest title/name, not an assumed slug. +`STORYBOOK_SMOKE_STORY` filters authored callbacks, not catalog traversal. +The accessible lane executes the Button semantics callback; its declared +physical-pointer callback is excluded at selection, not counted as passed +or skipped. + +Failed exploratory Fabric attempts remain separate artifacts: +`fabric-prep-json3-failure/`, `fabric-orchestrator-scope-failure/`, +`fabric-ax-startup-failure/` and `fabric-orchestrator-refusal-failure/`. +The latter three corrected qualifier assumptions: the transitional outer +claim is not a registered neutral claim; initial AX queries can be incomplete; +and native conflict metadata is not the WebDriver error shape. They did not +change production behavior. The scope failure required scoped command-tree +shutdown; its three ports and claim were then verified absent in +`driver-exact-cleanup.json`. The startup failure occurred before the qualifier +captured a lease identity, so its original zero cleanup-failure count is not +proof that its app exited. Operator cleanup separately checked the unique +native PID, worktree executable, instance bundle ID and start within the +successful `run` interval before signaling that exact app; the native probe +reported exited in `run-candidate-exact-cleanup.json`. No unknown process or +foreign application was adopted or killed. + +#### Observed renderer results + +| Renderer | Qualification checks passed / failed / skipped | Smoke selected / executed / passed / failed / skipped / unsupported | Smoke artifact | +| ------------ | ---------------------------------------------- | ------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| macOS Fabric | 9 / 0 / 0; cleanup failures 0 | 224 / 224 / 224 / 0 / 0 / 0; retries 0 | `apps/storybook/artifacts/desktop/616bf2e8-a675-408f-bd56-2e09982ea969/run.json` | +| macOS Paper | 9 / 0 / 0; cleanup failures 0 | 224 / 224 / 224 / 0 / 0 / 0; retries 0 | `apps/storybook/artifacts/desktop/19358539-eed2-49e9-9c9a-79b5e7db4e9f/run.json` | + +Each smoke traversed 223 catalog entries plus the one manifest-selected +Button semantics callback. The accessible input policy excluded one physical +callback before selection; no physical callback is included in those passes. +Both renderer doctors observed the same stable signed helper artifact and +effective permissions recorded above. This qualifies the observed Fabric and +Paper signing/TCC responsibility path separately; it is not first-run, +clean-machine, PPPC or revoked-permission qualification. + +| Renderer | Externally attached run-owned app, PID / native start | Supervisor-owned app, PID / native start | Exact cleanup | +| -------- | ----------------------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| Fabric | 82677 / `2026-11-12T10:56:35.279027Z` | 83427 / `2026-11-12T10:56:38.528966Z` | External app preserved through both external owners, then explicitly quit; supervisor app exited at owner disposal | +| Paper | 93753 / `2026-11-12T11:03:02.610130Z` | 94499 / `2026-11-12T11:03:04.921261Z` | Same independently observed preservation and exact disposal | + +Both renderer runs closed driver/channel/Metro ports 33637, 18878 and 38843 +before restart and after smoke, removed all neutral and outer CLI claims, +and found the instance application absent after smoke. Raw checks, identities, +claims, process ancestry, signing/preflight and command logs: +`artifacts/desktop/f10m/ax-qualification-20261112/fabric/evidence.json` +and +`artifacts/desktop/f10m/ax-qualification-20261112/paper/evidence.json`. + +**Still not run/unqualified:** arbitrary surviving descendants, interrupted +native publication, supervisor-death survivors and live failed-cleanup/restart +fault injection; native Windows/Win32 packaged-survivor journaling/recovery; +and the repeated-run reliability window. First-run durable helper-owned +TCC grants, clean-machine/PPPC/revocation behavior and another responsible +process tree are not inferred from these passes. Storybook lifecycle/lease +relocation remains deferred after F9. **Windows CI sufficient: no; keep draft.** +Apple/Office module and bridging-header checks are inapplicable to this +standalone helper. + +#### Final package validation and scope correction + +**Observed.** All three owning packages passed their declared +`yarn workspace @fluentui-react-native/ format --check`, `build` +and `lint` commands, for `desktop-driver`, `desktop-test` and +`storybook-desktop`. The harness build/format/lint were repeated after its +regression-fixture refinement. Command log: `package-validation.log` in the +qualification artifact root. + +The initial broad native-enabled batch passed the full driver suite, then +failed the harness suite: **416 passed, 3 failed, 1 skipped**. Two portable +CLI fixtures intentionally substitute HOME, but inherited the native signing +override, making the signing identity unavailable in that isolated home. +Those are not portable results without native environment overrides. +The third failure was a LaunchServices acquisition in the discovery test's +setup: the helper could not prove causal incarnation, returned +`applicationSettlement=unknown` with PID 9037, and did not kill a guessed +process. Its precise cause remains unestablished. It is not a successful +launch or a resolved reliability gate. + +Operator cleanup verified the unique run-owned fixture executable, exact +kernel start `2026-11-12T11:12:39.767964Z` and native-suite interval before +signaling PID 9037; the native probe then proved exited. Evidence: +`native-suite-candidate-cleanup.json` and +`packages/agentic/desktop-test/artifacts/desktop/application-ownership-Kd4tXB/evidence.json`. +The discovery-only regression now starts both external children directly, +captures their native identities, and waits for exact attached windows. +It still warms discovery before launch, observes through another controlling +helper, refuses duplicate bundle identities and verifies disappearance. +This removes unrelated LaunchServices setup from that regression; it neither +fixes nor weakens causal ownership capture. Existing supervisor/causal-launch +tests remain selected. + +The corrected validation keeps the signing/native opt-ins only on matching-OS +native selections and clears them for portable full suites. Every command +below ran under the native lock with the default TMPDIR unchanged: + +| Selection | Exact command | Passed / failed / skipped | +| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | +| Full signed native-enabled driver | `yarn workspace @fluentui-react-native/desktop-driver test --runInBand --cacheDirectory "$PWD/artifacts/desktop/f10m/ax-qualification-20261112/jest-desktop-driver-full"` | 1,255 / 0 / 47 | +| Final signed ownership and regressions | `yarn workspace @fluentui-react-native/desktop-test test --runInBand applicationOwnership.contract applicationOwnership.test ownership.test --cacheDirectory "$PWD/artifacts/desktop/f10m/ax-qualification-20261112/jest-native-harness"` | 39 / 0 / 0; all 10 native tests selected | +| Full portable driver | `yarn workspace @fluentui-react-native/desktop-driver test --runInBand --cacheDirectory "$PWD/artifacts/desktop/f10m/ax-qualification-20261112/jest-desktop-driver-portable"` | 1,236 / 0 / 66 | +| Full portable harness | `yarn workspace @fluentui-react-native/desktop-test test --runInBand --cacheDirectory "$PWD/artifacts/desktop/f10m/ax-qualification-20261112/jest-desktop-test-portable"` | 409 / 0 / 11 | +| Full portable Storybook | `yarn workspace @fluentui-react-native/storybook-desktop test --runInBand --cacheDirectory "$PWD/artifacts/desktop/f10m/ax-qualification-20261112/jest-storybook-desktop-portable"` | 344 / 0 / 4 | + +Signed/native rows use `FURN_NATIVE_DRIVER_TEST=1`, the signing identity and +helper cache above; the ownership row additionally enables +`FURN_NATIVE_PHYSICAL_INPUT_TEST=1`. Portable rows explicitly unset +`FURN_NATIVE_DRIVER_TEST`, `FURN_NATIVE_PHYSICAL_INPUT_TEST`, +`FURN_DESKTOP_DRIVER_MACOS_SIGNING_IDENTITY`, +`FURN_DESKTOP_DRIVER_CACHE_ROOT` and `FURN_DESKTOP_DRIVER_BUILD_POLICY`. +The 11 portable harness skips include all 10 opt-in AppKit tests; their +separate signed pass is not inferred from that portable run. + +Logs: `full-native-suites.log` (includes the failed broad attempt) and +`validation-final.log` (all corrected selections passed), under +`artifacts/desktop/f10m/ax-qualification-20261112/`. +Final AppKit evidence: +`packages/agentic/desktop-test/artifacts/desktop/application-ownership-1V0SrR/evidence.json`, +with the unchanged signed helper/source digest qualified by both renderers. +**Still unqualified:** reliable LaunchServices causal-identity acquisition +when the native snapshot cannot be proven; the unknown-publication/survivor +and other gates listed above remain open. No automatic reclaim is claimed. + +#### Final aggregate and cleanup + +**Observed.** The single final clean-cache aggregate ran under the exclusive +native-validation lock, without changing the default TMPDIR: + +```sh +"$HOME/Library/Caches/furn-overnight/with-native-lock.sh" \ + env -u FURN_NATIVE_DRIVER_TEST -u FURN_NATIVE_PHYSICAL_INPUT_TEST \ + -u FURN_DESKTOP_DRIVER_MACOS_SIGNING_IDENTITY \ + -u FURN_DESKTOP_DRIVER_CACHE_ROOT -u FURN_DESKTOP_DRIVER_BUILD_POLICY \ + TF_BUILD=1 bash -c 'set -euo pipefail; +node -e '\''console.log(JSON.stringify({tmpdir:require("node:os").tmpdir(),nativeOptIn:process.env.FURN_NATIVE_DRIVER_TEST??null}))'\''; +yarn clean-lage-cache && yarn lage buildci --concurrency 1' +``` + +**Passed 178 tasks, failed 0, skipped 0, pending 0, aborted 0**, in +6m 50.47s. The recorded Node snapshot confirms the default macOS temporary +directory and no native opt-in. The portable aggregate does not replace the +separate signed native or real-renderer qualification. Full output: +`artifacts/desktop/f10m/ax-qualification-20261112/buildci-final.log`. + +After the aggregate, the final read-only cleanup verification ran: + +```sh +"$HOME/Library/Caches/furn-overnight/with-native-lock.sh" \ + env FURN_DESKTOP_DRIVER_MACOS_SIGNING_IDENTITY="FURN Desktop Driver Development" \ + FURN_DESKTOP_DRIVER_CACHE_ROOT="$PWD/artifacts/desktop/f10m/helper" \ + FURN_DESKTOP_DRIVER_BUILD_POLICY=never \ + node artifacts/desktop/f10m/ax-qualification-20261112/verify-cleanup.mjs +"$HOME/Library/Caches/furn-overnight/with-native-lock.sh" true +``` + +**Passed:** signed public native probes reported all **31** recorded exact +incarnations exited; all three reported driver/channel/Metro ports refused +connections; scoped current-user command-line and working-directory +inspection found **0** worktree workload processes. Only the verifier, its +command ancestors and its `lsof` child were excluded. The shared workload +claim was absent, with **0** remaining `interactive.lock` files in eight +native/renderer/failed-attempt artifact roots. Neither unknown nor foreign +processes were signaled. The locked verifier exited 0; the following locked +`true` successfully reacquired and released the machine-wide lock. + +Evidence: `cleanup-final.json` and `cleanup-final.log` under +`artifacts/desktop/f10m/ax-qualification-20261112/`. The qualifier, installed-gem +selector and final verifier remain as ignored execution artifacts; the +experimental Swift diagnostic and completed operator-cleanup scratch scripts +were removed, preserving their JSON/log evidence. No helper, fixture, app, +driver service or Metro from this worktree remains. The causal-acquisition, +survivor, containment, interrupted-publication and TCC/reliability limitations +above are unchanged; keep the PR draft. + +Final documentation checks passed: + +| Exact command | Observed result | +| ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | +| `yarn markdown-link-check -c .github/markdown-link-check-config.json packages/agentic/desktop-driver/native/macos/README.md` | Passed 12 links | +| `yarn markdown-link-check -c .github/markdown-link-check-config.json packages/agentic/desktop-driver/references/current-status.md` | Passed 6 links | +| `yarn markdown-link-check -c .github/markdown-link-check-config.json packages/agentic/desktop-test/references/current-status.md` | Passed 15 links | +| `yarn markdown-link-check -c .github/markdown-link-check-config.json .changeset/neutral-macos-application-ownership.md` | Passed; 0 links | +| `yarn lage test-links --verbose --grouped` | Passed 90 tasks, failed 0, skipped 0 | +| `yarn oxfmt --check` | Passed repository-wide, 3,159 files | + +Logs in the qualification artifact root: +`changed-markdown-links-final.log`, `test-links-final.log` and +`root-format-final.log`. `git diff --check` also passed. Only documentation +result capture followed the final aggregate; no native/runtime source +changed after the separately qualified renderer builds. + +### Rebased F10-M publication and survivor qualification — 2026-11-12 + +The macOS layer was rebased in place from +`6489386956c0485cb91b478a8f4adc4828f4178b` onto main +`041ac8b9894aeaa7e1aa273baa2ece4e46298b94` (F6), yielding +`d58d473b7bbef26619db596f420fa837687badf3`. The awaited Storybook claim +was already present in F6; conflict resolution retained it and both independent +evidence ledgers. No Windows native source was changed. +`yarn install --immutable` passed with existing peer-dependency warnings. + +Qualification-only commit +`d5a7530a9f2d4cb78721a36c1109b5c36fbdc5a1` adds three real AppKit +scenarios; production ownership and helper behavior are unchanged: + +- Acquired-resource publication throws after native launch. The complete + pending claim remains fenced, refuses release/replacement, and settles only + after exact native cleanup and successful publication. +- An independent owner is killed with `SIGKILL` after native acquisition but + before acquired-identity publication. The durable pending record survives; + no guessed identity is adopted, and replacement remains refused. +- Another independent owner is killed after acquired-identity publication. + Its native app and known `/bin/sleep` descendant survive owner/helper exit. + Read-only inspection verifies the surviving app. The descendant also + survives exact primary-process cleanup, confirming `uncontained` semantics. + Both crash tests verify native owner/helper/app/descendant identities and + the initial child-parent relationship before exact operator cleanup. Only + the unchanged fixture claim is then removed. + +**Observed:** macOS 27.2.1 arm64, stable signer +`FURN Desktop Driver Development`. All native operations ran under the shared +native lock, with inherited TMPDIR. Nonprompting doctor passed with +Accessibility/PostEvent/ScreenCapture preflight true and unlocked/on-console. +The helper artifact and source digest remain +`1429ce4663795bad31f545086ef80bffb83e9b9533287964991c5527388b2f6c` +and `c17944691081d30a320cf6e4393a5bb0ce6cece8dd71295a400dbc5e6a53b3cb`. + +The focused command below passed **42 tests: 13 native, 29 portable; no failures +or skips**. It ran the exact fixture/test content subsequently committed above. + +```sh +FURN_NATIVE_DRIVER_TEST=1 FURN_NATIVE_PHYSICAL_INPUT_TEST=1 \ +FURN_DESKTOP_DRIVER_MACOS_SIGNING_IDENTITY="FURN Desktop Driver Development" \ +FURN_DESKTOP_DRIVER_CACHE_ROOT="$PWD/artifacts/desktop/f10m/helper" \ +yarn workspace @fluentui-react-native/desktop-test test --runInBand \ + applicationOwnership.contract applicationOwnership.test ownership.test \ + --cacheDirectory "$PWD/artifacts/desktop/f10m/rebased-qualification-20261112/jest-ownership" +``` + +Evidence: `packages/agentic/desktop-test/artifacts/desktop/application-ownership-kffNMN/evidence.json`. +Raw command output is under +`artifacts/desktop/f10m/rebased-qualification-20261112/`. + +Full package validation also passed: + +| Exact command | Observed result | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | +| `yarn workspace @fluentui-react-native/desktop-driver format --check` | Passed | +| `yarn workspace @fluentui-react-native/desktop-driver lint` | Passed | +| `yarn workspace @fluentui-react-native/desktop-driver build` | Passed | +| `yarn workspace @fluentui-react-native/desktop-test format --check` | Passed | +| `yarn workspace @fluentui-react-native/desktop-test lint` | Passed | +| `yarn workspace @fluentui-react-native/desktop-test build` | Passed | +| `yarn workspace @fluentui-react-native/storybook-desktop format --check` | Passed | +| `yarn workspace @fluentui-react-native/storybook-desktop lint` | Passed | +| `yarn workspace @fluentui-react-native/storybook-desktop build` | Passed | +| `yarn workspace @fluentui-react-native/desktop-driver test --runInBand --cacheDirectory "$PWD/artifacts/desktop/f10m/rebased-qualification-20261112/jest-driver-native"` | Passed 1,256; failed 0; skipped 47. Native opt-in, stable signing and the helper cache above were set for this command only. | +| `yarn workspace @fluentui-react-native/desktop-test test --runInBand --cacheDirectory "$PWD/artifacts/desktop/f10m/rebased-qualification-20261112/jest-harness"` | Passed 409; failed 0; skipped 14. Native/signing overrides absent; the 13 AppKit cases passed separately. | +| `yarn workspace @fluentui-react-native/storybook-desktop test --runInBand --cacheDirectory "$PWD/artifacts/desktop/f10m/rebased-qualification-20261112/jest-storybook"` | Passed 392; failed 0; skipped 4. Native/signing overrides absent. | + +The native driver suite includes exact-incarnation probes, refusal of +unreadable foreign process identity, stale-start/PID-reuse classification and +native self-tests. Stale timestamps supplied against live PIDs and synthetic +microsecond-reuse self-tests are not an observation of forced OS PID recycling. +The physical ownership mutex uses only bounded Shift down/up. + +**Remaining scope:** the crash fixtures prove a bounded known descendant, +not arbitrary descendant discovery or containment. They interrupt acquisition +publication, not a machine power loss during filesystem rename. Actual TCC +revocation, helper-owned durable grants outside the observed responsible +process tree, automatic survivor adoption, Metro/channel survivor topology, +transparent restart and repeated-run reliability remain unqualified. +The earlier unverifiable LaunchServices causal snapshot is not erased by these +passing launches. F9 lifecycle relocation and F11 retained-app handoff are +separate work; this change implements neither. + +#### Rebased Fabric and Paper consumer passes + +Both renderers were independently rebuilt/prepared and qualified at +`ca88c289c0fbd79b03dbd43b9bd85f0b21e7e800` (documentation after the +qualification-only commit). Each passed **all 9 ownership/lifecycle checks**, +with **0 cleanup failures**. Their declared accessible smokes each passed +**224, failed 0, skipped 0, unsupported 0, retries 0**: 223 catalog traversals +and one manifest-selected Button semantics callback. One physical callback +was excluded before selection, not passed or skipped. + +Commands ran from `apps/storybook`, separately for Fabric and Paper, under the +native lock. The helper signing/cache settings above were supplied. +The existing project-local Ruby preload selected installed JSON 2.18.0 for +CocoaPods; no dependencies, privacy settings, native project sources or +global Ruby settings were changed. + +| Exact command | Observed result | +| --------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | +| `yarn storybook --reporter json instance --macos` | Passed | +| `yarn storybook --reporter json bundle --macos` | Passed | +| `yarn storybook --reporter json prep --macos` | Passed | +| `yarn storybook --reporter json manifest --macos` | Passed | +| `yarn storybook --reporter json driver --macos --input accessible` | Passed readiness and settled shutdown | +| `yarn storybook --reporter json run --macos` | Passed native build and exact instance launch | +| `STORYBOOK_SMOKE_STORY=components-button--default yarn storybook --reporter json smoke --macos --mode stories-and-tests --input accessible` | Passed 224/224 | +| `yarn storybook --reporter json instance --macos --paper` | Passed | +| `yarn storybook --reporter json bundle --macos --paper` | Passed | +| `yarn storybook --reporter json prep --macos --paper` | Passed | +| `yarn storybook --reporter json manifest --macos --paper` | Passed | +| `yarn storybook --reporter json driver --macos --paper --input accessible` | Passed readiness and settled shutdown | +| `yarn storybook --reporter json run --macos --paper` | Passed native build and exact instance launch | +| `STORYBOOK_SMOKE_STORY=components-button--default yarn storybook --reporter json smoke --macos --paper --mode stories-and-tests --input accessible` | Passed 224/224 | +| `yarn workspace @fluentui-react-native/desktop-driver exec desktop-driver --reporter json doctor --platform macos --permissions` | Passed separately for each renderer; no prompt | + +Real neutral supervisor-owned apps survived two borrowed sessions and exited +only at supervisor disposal. Externally attached apps survived both independent +owners' cleanup. Cross-helper controlling attach returned +`application-lock-busy`; independent WebDriver control was refused until the +first lease was released. Read-only inspection succeeded while control was +held and preserved window handles. Schema-3 claims carried verified native +owner/application and registered target/instance identities. F6 provenance +behavior remained intact. Storybook's outer CLI claim still has its documented +transitional placeholders; this is not F9 integration. + +Evidence: `fabric/evidence.json` and `paper/evidence.json` under the new +qualification artifact root. Smoke reports: +`apps/storybook/artifacts/desktop/15518d7a-d662-49ae-a76d-f4c0cd5e2fc3/run.json` +(Fabric) and +`apps/storybook/artifacts/desktop/92d22b26-24cc-4d87-a4b7-229d24f478b1/run.json` +(Paper). Both observed the same stable signer/designated requirement with +effective permissions under the `Rex Server.app` responsible process tree; +this is not first-run, clean-machine or permission-revocation qualification. +Apple/Office module and bridging-header checks are inapplicable to this +standalone helper. Windows/Win32 native ownership qualification belongs to +the independent upper layer and was not run here. + +#### Plain aggregate, documentation and final cleanup + +The required aggregate **is not green**. It ran under the native lock with +inherited environment: `TF_BUILD` and native/signing overrides absent, +default TMPDIR retained, and no concurrency override. The cache was cleared +once; subsequent plain attempts used only results from that fresh run. + +| Exact command | Observed result | +| ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `yarn lage test-links --verbose --grouped` | Passed 90 tasks, failed 0, skipped 0 | +| `yarn clean-lage-cache && yarn lage buildci` | Failed in `check-package-contents`: desktop-test's `yarn pack --dry-run --json` exited 1; underlying cause unestablished. Log: `buildci.log`. | +| `yarn workspace @fluentui-react-native/desktop-test pack --dry-run --json` | Passed immediately without changes. Log: `desktop-test-pack.log`. | +| `yarn lage buildci` (first retry) | Failed driver tests: known `boundedCommand` parallel-startup assertion expected SIGKILL but observed SIGTERM; unmodified `privateStorage` test also exceeded 10 seconds. Log: `buildci-retry.log`. | +| `yarn workspace @fluentui-react-native/desktop-driver test --runInBand boundedCommand privateStorage` | Passed 73, failed 0, skipped 10. This focused serial diagnostic is not aggregate acceptance. Log: `aggregate-failures-focused.log`. | +| `yarn lage buildci` (final unchanged retry) | Failed only the same `boundedCommand` assertion; driver tests passed 1,236, failed 1, skipped 66. The private-storage test passed. Log: `buildci-final.log`. | + +Both failing test sources and their implementations are unchanged from pinned +main. No assertion, timeout, concurrency policy or environment was altered to +hide these failures. The passing full signed suite, focused contracts and +renderer runs remain distinct evidence; they do not turn the failed plain +aggregate into a pass. + +Changed Markdown link checks passed using +`yarn markdown-link-check -c .github/markdown-link-check-config.json `: +`packages/agentic/desktop-driver/native/macos/README.md` (12 links), +`packages/agentic/desktop-driver/references/current-status.md` (6), +`packages/agentic/desktop-test/references/current-status.md` (19), +`packages/agentic/desktop-test/references/native-qualification.md` (2), and +`.changeset/neutral-macos-application-ownership.md` (0). + +**Observed final cleanup:** native probes proved all **24** recorded exact +incarnations exited; all **3** reported driver/channel/Metro ports refused +connections. Scoped command-line and working-directory checks found **0** +worktree workload processes. The shared claim and all claims beneath the +three native/renderer evidence roots were absent. The native lock was then +reacquired and released successfully. + +An earlier cleanup scan saw four orphaned test workers/children from the +failed aggregate. All four exited naturally before the bounded cleanup +inspection; no signal was needed. Their initial failure and subsequent +absence are retained in `cleanup-first.json`, `cleanup-first.log` and +`aggregate-worker-cleanup.json`. The final successful verifier wrote +`cleanup-final.json` and `cleanup-final.log` in the new artifact root. +No helper, fixture, descendant, application, driver service or Metro from +this qualification remains. Copied execution scripts were removed after use; +their JSON results and command logs remain as ignored evidence. + +#### Review follow-up: bounded fixture retirement — 2026-11-12 + +Source commit `0fb6cd47fccc4f6210797b25085e7b56aa8ca4fe` addresses the +reviewed indefinite crash-owner wait. The changed executable source is limited +to `fixtures/application-ownership/crash-owner.mjs`, its regression in the +existing `src/applicationOwnership.test.ts`, and four lines in +`packages/agentic/desktop-driver/native/macos/fixtures/ApplicationFixture.swift`. +The ready owner now waits +at most 120 seconds, emits a fixed diagnostic and exits 1. The AppKit fixture +requests termination on its main queue after 300 seconds; its existing +`/bin/sleep 300` descendant was already bounded. + +Production ownership, helper protocol, parent cleanup, identity verification +and claim settlement are unchanged. In particular, failure to obtain an owner +identity still does not authorize signalling a guessed PID. Owner retirement +allows helper EOF; neither fixture deadline settles an abandoned workload +claim or implements automatic recovery. + +**Observed portable regression:** with the shared wait still unbounded, the +new `--retirement-probe` test failed with `spawnSync ETIMEDOUT` after five +seconds. After adding the timeout, the same probe exited 1 with the expected +diagnostic. It uses a 25 ms deadline through the same wait function, before +acquiring any claim or starting a helper/application. This is portable +retirement evidence, not a native owner-death or five-minute AppKit runtime +pass. + +Exact commands and results at the source commit: + +- `node --check packages/agentic/desktop-test/fixtures/application-ownership/crash-owner.mjs`: + passed. +- `yarn workspace @fluentui-react-native/desktop-test test --runInBand applicationOwnership.test ownership.test`: + passed 30, failed 0, skipped 0. +- `yarn workspace @fluentui-react-native/desktop-driver format`, + `yarn workspace @fluentui-react-native/desktop-driver lint` and + `yarn workspace @fluentui-react-native/desktop-driver build`: each passed. +- `yarn workspace @fluentui-react-native/desktop-test format`, + `yarn workspace @fluentui-react-native/desktop-test lint` and + `yarn workspace @fluentui-react-native/desktop-test build`: each passed. +- `yarn workspace @fluentui-react-native/desktop-driver test --runInBand`: + passed 1,237, failed 0, skipped 66; native opt-in absent. +- `yarn workspace @fluentui-react-native/desktop-test test --runInBand`: + passed 410, failed 0, skipped 14; native opt-in absent. + +Under the shared native lock, using the existing stable signing identity and +helper cache, these commands also completed: + +```sh +yarn workspace @fluentui-react-native/desktop-driver exec desktop-driver \ + --reporter json doctor --platform macos --permissions +node packages/agentic/desktop-driver/native/macos/fixtures/build.cjs \ + artifacts/desktop/f10m/retirement-followup-20261112/fixture +``` + +Doctor exited 0 with `promptRequested=false`, Accessibility/PostEvent/ +ScreenCapture preflight true, and **`screenLocked=true`**. The fixture +compiled and passed strict signature verification; it was not launched. +**Current native runtime qualification is blocked by the locked console.** +The prior 13 native cases and Fabric/Paper passes above remain evidence of +pre-fix content, present at `16558610ff2bd14775430f86c738c12947fe81ee`; +they are not reruns of this retirement change. No unlock, privacy alteration +or native GUI retry was attempted. + +The one required follow-up `yarn clean-lage-cache && yarn lage buildci` +cleared the cache, then **failed** only the unchanged +`boundedCommand.test.ts:31` SIGKILL-versus-SIGTERM assertion: driver tests +passed 1,236, failed 1, skipped 66. Private-storage tests passed in this run. +Both implicated test files and their implementations are byte-unchanged from +pinned main `041ac8b9894aeaa7e1aa273baa2ece4e46298b94`. +The aggregate used the native lock, inherited TMPDIR, no `TF_BUILD`, no native +opt-in and no concurrency override. It was not retried. Root instruction, +package-content and publishing checks passed within that failed aggregate; +these individual passes do not make the aggregate green. + +Separate `yarn lage test-links --verbose --grouped` passed 90 tasks. +`yarn markdown-link-check -c .github/markdown-link-check-config.json ` +passed for the five changed Markdown files listed above, with unchanged +12/6/19/2/0 link counts. + +Final locked command-line/working-directory audits at 23:23:50 and 23:23:53 +UTC found no worktree workloads or unreadable candidates and issued no +signals. The initial audit conservatively counted its own short-lived +`lsof` child; the corrected audit excludes only that exact known audit PID +and its own ancestor chain. No application, Metro or native ownership +fixture was launched in this follow-up. + +Evidence: `artifacts/desktop/f10m/retirement-followup-20261112/`, including +`retirement-unbounded-red.log`, `retirement-green.log`, +`driver-portable.log`, `harness-portable.log`, `doctor.json`, +`fixture-build.json`, `plain-aggregate.log`, `test-links.log`, +`changed-markdown-links.log`, `cleanup.json` and `cleanup-final.json`. + ## F15 macOS feasibility and resolved permission block — 2026-11-12 The table below preserves the initial, non-AX pass. The permission prerequisite diff --git a/packages/agentic/desktop-test/references/lifecycle.md b/packages/agentic/desktop-test/references/lifecycle.md index d8cf5dc005..3aa581dca9 100644 --- a/packages/agentic/desktop-test/references/lifecycle.md +++ b/packages/agentic/desktop-test/references/lifecycle.md @@ -14,8 +14,11 @@ and disposal are tracked as one resource generation. Dependencies are validated and topologically ordered before acquisition. Owned resources are disposed in reverse order; borrowed resources are recorded but never disposed. -A launched application's lifetime is currently session-owned; attached -applications have an external owner. Storybook still takes over the CLI lifecycle, +A launched application's lifetime defaults to session-owned. macOS targets may +opt into `ownership: 'supervisor'`: the supervisor launches once before service +publication, lends attached leases to sessions, and closes the original exact +incarnation only at supervisor disposal. Attached applications always have an +external owner. Storybook still takes over the CLI lifecycle, with channel and embedded driver listeners in a child process. This exception is transitional, not a fully migrated resource graph. An integration's `run` borrows the supervisor's service scope: it observes the scope signal and must never close @@ -117,17 +120,50 @@ reported as a cleanup failure, and disposal proceeds. A resource observer that throws does not stop disposal; its failure is reported after cleanup. A failed or timed-out disposal remains unsettled and retains the claim. -The versioned workload record stores a random owner ID, process ID and recorded -start timestamp, run ID, registered target ID, physical target instance ID, and each +Schema 3 workload records store a random owner ID, process ID and optional native +process identity, run ID, registered target ID, physical target instance ID, and each resource's identity, generation, ownership, readiness, and disposal state. Unresolved effects fence the claim. Claim inspection distinguishes a live owner, an exited owner, an unknown probe, and a reused PID; inspection never reclaims a -claim, and uncertain or unsettled resources prevent release. The recorded start -timestamp comes from the Node.js clock, not from the operating system, and -inspection compares it exactly, so a probe that reports the operating-system start -time classifies a live owner as a reused PID. Treat `pid-reused` as unproven until -the claim records a helper-described start time. Reporting-session -inheritance is separate from explicit workload-claim delegation. +claim, and uncertain or unsettled resources prevent release. Native owner identity +comes from desktop-driver's OS process-identity probe, not the Node.js clock. +Schema 2 records remain readable but inspect as `unknown` without probing their +incompatible wall-clock timestamp. A schema 3 record without a verified native +identity also remains `unknown`; it never becomes a guessed live or reused PID. +`claimDesktopWorkload` is asynchronous so it can resolve that identity before +publication. Low-level/custom callers may supply a trusted identity or leave it +unverified. A missing prebuilt helper permits an unverified claim; verification +or permission errors are not silently downgraded. Reporting-session inheritance +is separate from explicit workload-claim delegation. + +## Application resources and exact attach + +The neutral host records a pending application resource before dispatching a +launch. An acquired resource adds its lease ID, native PID/start timestamp, +lifetime, containment, and causal launch kind when the provider supplies them. +These records appear in the workload claim alongside services and survive a +failed cleanup. Publication failure prevents dispatch or fences an already +dispatched launch. A failed/cancelled unleased launch settles when the provider +proves it never started or its exact process exited. A legacy provider's ordinary +no-data rejection does not imply a created application. Reported process identity, +an unknown settlement, lost transport after dispatch, or completion after +cancellation stays unresolved. Advisory exit events, duplicate lease IDs, a helper +exit, and service replacement never settle an acquired or uncertain application. + +The `/application-ownership` leaf provides `captureDesktopApplicationIdentity` +and `desktopApplicationIdentityDescriptor`. On macOS they require the native +six-fractional-digit OS timestamp and select an exact external incarnation +without a Storybook lease or transferred ownership. Do not construct that +identity from `Date`, LaunchServices dates, a title, or a bundle ID. Existing +Storybook attach-lease code is deliberately unchanged pending its integration +migration. + +macOS launched leases report `launch-services` or `direct-child`; both are +`uncontained`. Exact primary-process cleanup does not prove arbitrary surviving +descendants exited. Independent native helpers exclude the same exact app using +a stable per-user application lock, separately from the workload/input locks. +Supervisor crash recovery, interrupted publication without a captured identity, +and arbitrary descendant reconciliation remain explicit delivery gates. Both native service paths use the real `driverService.ts` composition root (`@fluentui-react-native/desktop-test/driver-service`). It resolves prebuilt diff --git a/packages/agentic/desktop-test/references/native-qualification.md b/packages/agentic/desktop-test/references/native-qualification.md index 00a68c56af..1b59bd04ce 100644 --- a/packages/agentic/desktop-test/references/native-qualification.md +++ b/packages/agentic/desktop-test/references/native-qualification.md @@ -22,6 +22,78 @@ real descriptor path. Run the `yarn storybook`, `yarn desktop-driver`, and `yarn desktop-test` commands below from `apps/storybook`, where their scripts are declared. +## Neutral macOS application ownership + +The package's opt-in AppKit contract builds the signed native helper and the +isolated fixture described in the +`packages/agentic/desktop-driver/native/macos/README.md` provider guide. +From the repository root, under the exclusive native-validation lock: + +```sh +FURN_NATIVE_DRIVER_TEST=1 yarn workspace @fluentui-react-native/desktop-test test --runInBand applicationOwnership.contract +``` + +Set `FURN_DESKTOP_DRIVER_MACOS_SIGNING_IDENTITY` to the provisioned stable +identity. The suite covers supervisor/session separation, durable owner/app +identities, launch-capable discovery, exact external-app exclusion and +preservation, stale identities, direct-child causality, and post-launch +cancellation. Publication-failure coverage uses a real launched application +with an injected durable-publication rejection. Two separate Node owners are +killed after native acquisition: one before publishing the acquired identity, +the other after publishing it. Both leave a fenced claim, a surviving native +app and a bounded known descendant; replacement remains refused even after +the recorded owner and helper exit. The tests verify exact native identities +and executable paths before operator cleanup, prove the descendant survives +the primary process, and remove only the unchanged fixture claim after all +recorded processes exit. These are native AppKit scenarios, not fake-host +evidence or automatic recovery. + +The ready crash-owner fixture retires with exit code 1 after two minutes if +the parent cannot perform exact cleanup. Its portable `--retirement-probe` +executes the same wait with a short deadline without acquiring a claim or +starting a helper/application. The AppKit fixture requests its own termination +after five minutes, and its `/bin/sleep 300` descendant already has a finite +lifetime. Neither deadline clears a claim or authorizes guessed PID cleanup; +native identity verification is still required by the parent. + +Its evidence is written to the desktop-test package's +`artifacts/desktop/application-ownership-*/evidence.json`. The separate physical mutex +case requires explicit `FURN_NATIVE_PHYSICAL_INPUT_TEST=1` and effective native +permissions; a skipped case does not qualify input exclusion. + +When Accessibility is unavailable, the non-positive safety subset can still +qualify native OS-owner inspection, pre-spawn/LaunchServices rejection settlement +and claim reuse, and exact cleanup after rejected launch: + +```sh +FURN_NATIVE_DRIVER_TEST=1 yarn workspace @fluentui-react-native/desktop-test test --runInBand applicationOwnership.contract --testNamePattern 'kernel owner identity|failed native' +``` + +Record the actual AX rejection and exact primary-process cleanup. Passing this +subset is not positive supervisor lifetime, external-app preservation, +application exclusion, physical-input or renderer qualification. + +An Accessibility trust flag alone is insufficient: record actual AX calls and +failures, including `kAXErrorAPIDisabled`, without resetting TCC or changing +privacy permissions. AppKit results support only the generic backend. They are +not Fabric/Paper signing or TCC-responsibility qualification, do not qualify +arbitrary descendants, and do not replace the per-renderer/descriptor gates +below. Windows launched-application resource/survivor qualification must run on +Windows; macOS and ordinary hosted smoke cannot establish it. + +`containment: "uncontained"` is deliberate for both macOS launch kinds. +An exact primary-process exit is not proof that arbitrary descendants exited. +The native ownership cases do not qualify descendant containment. +The crash cases qualify a known descendant and the interrupted acquisition +publication boundary, not arbitrary descendant discovery, filesystem +power-loss behavior, Metro/channel survivors or transparent service restart. + +The PR Windows setup action runs the **desktop-driver** native suite, not this +package's application-resource contracts. The Windows/Win32 E2E and Storybook +smoke jobs do not exercise failed packaged-launch survivor journaling/recovery. +Those jobs are insufficient for that acceptance gate; portable schema tests +must not be presented as Windows native qualification. + ## Parameters and prerequisites Record these for every renderer run: diff --git a/packages/agentic/desktop-test/src/adapters.ts b/packages/agentic/desktop-test/src/adapters.ts index 47d592bc4f..0a5b16e969 100644 --- a/packages/agentic/desktop-test/src/adapters.ts +++ b/packages/agentic/desktop-test/src/adapters.ts @@ -63,7 +63,7 @@ export async function resolveDesktopApplication( if (target.endpoint === 'macos') { const bundleIdentifier = app.macos?.bundleIdentifier ?? 'com.microsoft.ReactTestApp'; application = - configured.bundleIdentifier || configured.executablePath || configured.leasePath + configured.bundleIdentifier || configured.executablePath || configured.leasePath || configured.processIdentity ? { bundleIdentifier, windowTitle, ...configured } : { executablePath: await resolveBuiltMacosApplication(target, app, bundleIdentifier, signal), windowTitle, ...configured }; } else { @@ -107,7 +107,7 @@ export async function resolveDesktopApplication( } } if (target.endpoint === 'macos') { - if (!application.bundleIdentifier && !application.executablePath && !application.leasePath) + if (!application.bundleIdentifier && !application.executablePath && !application.leasePath && !application.processIdentity) throw new Error('macOS application identity is missing.'); if (application.workingDirectory) throw new Error('workingDirectory is currently supported only by the Windows helper.'); } else { diff --git a/packages/agentic/desktop-test/src/applicationOwnership.contract.test.ts b/packages/agentic/desktop-test/src/applicationOwnership.contract.test.ts new file mode 100644 index 0000000000..987f0ab327 --- /dev/null +++ b/packages/agentic/desktop-test/src/applicationOwnership.contract.test.ts @@ -0,0 +1,777 @@ +import { spawn, spawnSync } from 'node:child_process'; +import type { ChildProcess } from 'node:child_process'; +import { once } from 'node:events'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { NativeDesktopHost } from '@fluentui-react-native/desktop-driver'; +import type { ApplicationLease, NativeDesktopApplicationDescriptor, NativeDriverArtifact } from '@fluentui-react-native/desktop-driver'; +import { createDesktopDriverClient } from '@fluentui-react-native/desktop-driver/client'; +import { createMacOSApplicationProbe, describeMacOSService } from '@fluentui-react-native/desktop-driver/authority'; +import { buildNativeDesktopDriver } from '@fluentui-react-native/desktop-driver/native'; + +import { + captureDesktopApplicationIdentity, + createDesktopApplicationHost, + desktopApplicationIdentityDescriptor, +} from './applicationOwnership'; +import type { DesktopApplicationIdentity } from './applicationOwnership'; +import type { ResolvedDesktopTarget } from './config'; +import { createDesktopWorkloadClaim, describeDesktopWorkloadOwner, inspectDesktopWorkloadClaim } from './ownership'; +import { DesktopRunReporter } from './reporting/collector'; +import type { DesktopResourceRecord } from './resources'; +import { DesktopSupervisor } from './supervisor'; + +jest.setTimeout(300_000); + +const enabled = process.platform === 'darwin' && process.env.FURN_NATIVE_DRIVER_TEST === '1'; +const target = { endpoint: 'macos', id: 'ownership-fixture', platformName: 'macos', launchTimeoutMs: 15_000 } as const; +const evidence: Record[] = []; +let artifact: NativeDriverArtifact; +let directory: string; +let fixture: { bundle: string; executable: string; bundleIdentifier: string; windowTitle: string }; + +function recordNativeCase(name: string, run: () => Promise, physical = false) { + const selected = enabled && (!physical || process.env.FURN_NATIVE_PHYSICAL_INPUT_TEST === '1'); + (selected ? test : test.skip)(name, async () => { + try { + await run(); + } catch (error) { + const failure = { case: name, state: 'failed', error: errorDetails(error) }; + evidence.push(failure); + console.error(JSON.stringify(failure)); + throw error; + } + }); + if (enabled && !selected) evidence.push({ case: name, state: 'skipped', reason: 'Physical-input opt-in is not enabled.' }); +} + +function nativeTest(name: string, run: () => Promise) { + recordNativeCase(name, run); +} + +function errorDetails(error: unknown, depth = 0): unknown { + if (!(error instanceof Error) || depth > 4) return String(error); + return { + name: error.name, + message: error.message, + ...('data' in error ? { data: error.data } : {}), + ...(error instanceof AggregateError ? { errors: error.errors.map((entry) => errorDetails(entry, depth + 1)) } : {}), + }; +} + +async function withCleanup(run: () => Promise, cleanup: () => Promise): Promise { + try { + await run(); + } catch (error) { + try { + await cleanup(); + } catch (disposal) { + throw new AggregateError([error, disposal], 'Native application contract and cleanup failed.'); + } + throw error; + } + await cleanup(); +} + +beforeAll(async () => { + if (!enabled) return; + const artifacts = path.join(process.cwd(), 'artifacts', 'desktop'); + fs.mkdirSync(artifacts, { recursive: true }); + directory = fs.mkdtempSync(path.join(artifacts, 'application-ownership-')); + artifact = await buildNativeDesktopDriver({ platform: 'macos' }); + const driverRoot = path.dirname(require.resolve('@fluentui-react-native/desktop-driver/package.json')); + const built = spawnSync(process.execPath, [path.join(driverRoot, 'native/macos/fixtures/build.cjs'), path.join(directory, 'fixture')], { + encoding: 'utf8', + timeout: 180_000, + }); + if (built.error || built.status !== 0) throw built.error ?? new Error(built.stderr); + fixture = JSON.parse(built.stdout); +}); + +afterAll(() => { + if (!directory) return; + fs.writeFileSync( + path.join(directory, 'evidence.json'), + JSON.stringify( + { + recordedAt: new Date().toISOString(), + sourceRevision: spawnSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8', timeout: 5000 }).stdout?.trim() || 'unavailable', + workingTree: spawnSync('git', ['status', '--porcelain', '--untracked-files=no'], { encoding: 'utf8', timeout: 5000 }) + .stdout?.trim() + .split('\n') + .filter(Boolean), + osVersion: spawnSync('sw_vers', ['-productVersion'], { encoding: 'utf8', timeout: 5000 }).stdout?.trim() || 'unavailable', + architecture: process.arch, + platform: 'macos', + fixture: 'appkit', + helperArtifactId: artifact?.artifactId, + helperSourceDigest: artifact?.sourceDigest, + helperSigning: artifact?.signing, + cases: evidence, + }, + null, + 2, + ), + ); +}); + +function application(overrides: Partial = {}): NativeDesktopApplicationDescriptor { + return { executablePath: fixture.bundle, bundleIdentifier: fixture.bundleIdentifier, windowTitle: fixture.windowTitle, ...overrides }; +} + +function nativeHost(descriptor: NativeDesktopApplicationDescriptor): NativeDesktopHost { + return new NativeDesktopHost({ artifact, endpoint: 'macos', application: descriptor }); +} + +async function supervisor(name: string, descriptor: NativeDesktopApplicationDescriptor, lifetime: 'supervisor' | 'external') { + const output = { write: () => true }; + const reporter = new DesktopRunReporter({ artifactsRoot: path.join(directory, 'reports'), output, errorOutput: output, summary: false }); + const resolved: ResolvedDesktopTarget = { + ...target, + id: name, + registrationId: name, + instanceId: name, + projectRoot: directory, + configPath: path.join(directory, 'desktop.config.mts'), + host: { kind: 'installed', ownership: lifetime, application: descriptor }, + }; + const lockDirectory = path.join(directory, name); + const host = nativeHost(descriptor); + const owner = new DesktopSupervisor({ + target: resolved, + reporter, + reportSession: { reporter, delegated: true, environment: {}, close: async () => 0 }, + lockDirectory, + processIdentity: await describeDesktopWorkloadOwner({ helperPath: artifact.executablePath }), + hostFactory: async () => host, + recovery: { root: path.join(directory, `${name}-recovery`), probeApplication: createMacOSApplicationProbe({ artifact }) }, + }); + return { owner, host, lockDirectory, reporter }; +} + +nativeTest('kernel owner identity inspects the real claim as live without wall-clock conversion', async () => { + const processIdentity = await describeDesktopWorkloadOwner({ helperPath: artifact.executablePath }); + expect(processIdentity?.startedAt).toMatch(/\.[0-9]{6}Z$/); + const lockDirectory = path.join(directory, 'native-owner'); + const claim = createDesktopWorkloadClaim({ + runId: 'native-owner', + targetId: target.id, + targetInstanceId: target.id, + directory: lockDirectory, + processIdentity, + }); + try { + const inspection = await inspectDesktopWorkloadClaim(path.join(lockDirectory, 'interactive.lock'), async (owner) => ({ + state: 'alive', + processStartedAt: (await describeMacOSService({ artifact, pid: owner.processId })).startedAt, + })); + expect(inspection.state).toBe('live'); + evidence.push({ case: 'native-owner-identity', state: 'passed', processStartedAt: processIdentity!.startedAt }); + } finally { + claim.release(); + } +}); + +nativeTest('failed native pre-spawn and rejected LaunchServices launches release claims for subsequent workloads', async () => { + const descriptors: NativeDesktopApplicationDescriptor[] = [ + application({ processIdentity: { processId: process.pid, processStartedAt: '2026-11-12T00:00:00.123456Z' } }), + application({ executablePath: path.join(directory, 'missing-executable'), bundleIdentifier: undefined }), + application({ executablePath: undefined, bundleIdentifier: `com.microsoft.furn.missing.${path.basename(directory)}` }), + application({ executablePath: path.join(directory, 'Missing.app'), bundleIdentifier: undefined }), + ]; + for (const [index, descriptor] of descriptors.entries()) { + const claimOptions = { + directory: path.join(directory, `rejected-launch-${index}`), + runId: 'rejected-launch', + targetId: target.id, + targetInstanceId: target.id, + }; + const claim = createDesktopWorkloadClaim(claimOptions); + const host = await createDesktopApplicationHost(nativeHost(descriptor), { + target, + lifetime: 'session', + resourcePrefix: `rejected-launch-${index}`, + onResource: (resource) => claim.updateResource(resource), + }); + await withCleanup( + async () => { + await expect(host.launch({ ...target, host })).rejects.toMatchObject({ + data: { applicationSettlement: 'not-started' }, + }); + }, + () => host.dispose(), + ); + expect(claim.record.unresolvedEffects).toEqual([]); + claim.release(); + expect(fs.existsSync(path.join(claimOptions.directory, 'interactive.lock'))).toBe(false); + createDesktopWorkloadClaim({ ...claimOptions, runId: 'subsequent-workload' }).release(); + evidence.push({ case: 'rejected-launch-claim-release', state: 'passed', descriptorCase: index, settlement: 'not-started' }); + } +}); + +nativeTest('supervisor launch survives two real sessions, records native owner/application identity, and closes exactly', async () => { + const { owner, host, lockDirectory, reporter } = await supervisor('retained', application(), 'supervisor'); + const probe = createMacOSApplicationProbe({ artifact }); + let lease: ApplicationLease | undefined; + try { + await owner.start(); + const client = createDesktopDriverClient({ url: owner.server.url }); + expect(await client.status()).toMatchObject({ targets: [{ id: 'retained', launchModes: ['launch'] }] }); + const capabilities = { alwaysMatch: { platformName: 'macos', 'furn:target': 'retained' } }; + const first = await client.newSession(capabilities); + lease = owner.server.sessions.get(first.id).lease; + expect(lease).toMatchObject({ ownership: 'attached', launchKind: 'launch-services', containment: 'uncontained' }); + expect(captureDesktopApplicationIdentity(lease, 'macos').processStartedAt).toMatch(/\.[0-9]{6}Z$/); + const windows = await first.getWindowHandles(); + await expect(host.inspectApplication({ ...target, host })).resolves.toEqual({ + processId: lease.processId, + processStartedAt: lease.processStartedAt, + }); + await expect(first.getWindowHandles()).resolves.toEqual(windows); + await first.delete(); + await expect(probe(captureDesktopApplicationIdentity(lease, 'macos'))).resolves.toBe('running'); + const second = await client.newSession(capabilities); + expect(owner.server.sessions.get(second.id).lease).toEqual(lease); + const claimFile = path.join(lockDirectory, 'interactive.lock'); + const inspected = await inspectDesktopWorkloadClaim(claimFile, async (identity) => ({ + state: 'alive', + processStartedAt: (await describeMacOSService({ artifact, pid: identity.processId })).startedAt, + })); + expect(inspected.state).toBe('live'); + expect(inspected.record.resources).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + ownership: 'owned', + disposal: 'pending', + application: expect.objectContaining({ + processId: lease.processId, + processStartedAt: lease.processStartedAt, + lifetime: 'supervisor', + }), + }), + ]), + ); + } finally { + await owner.close(); + await reporter.finish(); + } + await expect(probe(captureDesktopApplicationIdentity(lease!, 'macos'))).resolves.toBe('exited'); + expect(fs.existsSync(path.join(lockDirectory, 'interactive.lock'))).toBe(false); + evidence.push({ + case: 'supervisor-lifetime-and-owner-identity', + state: 'passed', + processId: lease!.processId, + processStartedAt: lease!.processStartedAt, + }); +}); + +nativeTest('read-only discovery sees newly launched applications and refuses new duplicate bundle identities', async () => { + const observer = nativeHost(application()); + let child: ChildProcess | undefined; + let identity: DesktopApplicationIdentity | undefined; + let first: NativeDesktopHost | undefined; + let duplicate: ChildProcess | undefined; + let duplicateIdentity: DesktopApplicationIdentity | undefined; + let second: NativeDesktopHost | undefined; + try { + await expect(observer.inspectApplication({ ...target, host: observer })).rejects.toMatchObject({ + data: { reason: 'no-matching-application' }, + }); + child = spawn(fixture.executable, [], { stdio: 'ignore' }); + await once(child, 'spawn'); + const childIdentity = await describeMacOSService({ artifact, pid: child.pid! }); + identity = captureDesktopApplicationIdentity( + { id: 'first', ownership: 'attached', processId: child.pid!, processStartedAt: childIdentity.startedAt }, + 'macos', + ); + first = nativeHost(desktopApplicationIdentityDescriptor(identity, application())); + await first.attach({ ...target, host: first }); + await expect(observer.inspectApplication({ ...target, host: observer })).resolves.toMatchObject({ + processId: identity.processId, + processStartedAt: identity.processStartedAt, + }); + duplicate = spawn(fixture.executable, [], { stdio: 'ignore' }); + await once(duplicate, 'spawn'); + const nativeIdentity = await describeMacOSService({ artifact, pid: duplicate.pid! }); + duplicateIdentity = captureDesktopApplicationIdentity( + { id: 'duplicate', ownership: 'attached', processId: duplicate.pid!, processStartedAt: nativeIdentity.startedAt }, + 'macos', + ); + second = nativeHost(desktopApplicationIdentityDescriptor(duplicateIdentity, application())); + await second.attach({ ...target, host: second }); + await expect(observer.inspectApplication({ ...target, host: observer })).rejects.toMatchObject({ + code: 'ambiguous-target', + }); + await second.dispose(); + await stopFixtureChild(duplicate, duplicateIdentity); + await expect(observer.inspectApplication({ ...target, host: observer })).resolves.toMatchObject({ + processId: identity.processId, + processStartedAt: identity.processStartedAt, + }); + await first.dispose(); + await stopFixtureChild(child, identity); + await expect(observer.inspectApplication({ ...target, host: observer })).rejects.toMatchObject({ + data: { reason: 'no-matching-application' }, + }); + evidence.push({ + case: 'fresh-read-only-discovery-and-duplicate-bundle-refusal', + state: 'passed', + processId: identity.processId, + processStartedAt: identity.processStartedAt, + duplicateProcessId: duplicateIdentity.processId, + duplicateProcessStartedAt: duplicateIdentity.processStartedAt, + }); + } finally { + const cleanup = [ + first?.dispose(), + observer.dispose(), + duplicate ? cleanupExternalFixture(duplicate, duplicateIdentity, [second?.dispose()]) : second?.dispose(), + ]; + if (child) await cleanupExternalFixture(child, identity, cleanup); + else await Promise.all(cleanup); + } +}); + +nativeTest('independent services exclude the exact external application and preserve it after both cleanups', async () => { + const child = spawn(fixture.executable, [], { stdio: 'ignore' }); + let identity: DesktopApplicationIdentity | undefined; + let first: Awaited> | undefined; + let second: Awaited> | undefined; + let stale: NativeDesktopHost | undefined; + const probe = createMacOSApplicationProbe({ artifact }); + try { + await once(child, 'spawn'); + const nativeIdentity = await describeMacOSService({ artifact, pid: child.pid! }); + identity = captureDesktopApplicationIdentity( + { id: 'external', ownership: 'attached', processId: child.pid!, processStartedAt: nativeIdentity.startedAt }, + 'macos', + ); + const descriptor = desktopApplicationIdentityDescriptor(identity, application()); + first = await supervisor('external-first', descriptor, 'external'); + second = await supervisor('external-second', descriptor, 'external'); + stale = nativeHost({ + ...descriptor, + processIdentity: { + ...descriptor.processIdentity!, + processStartedAt: `${identity.processStartedAt.slice(0, -2)}${(Number(identity.processStartedAt.at(-2)) + 1) % 10}Z`, + }, + }); + await first.owner.start(); + await second.owner.start(); + const firstClient = createDesktopDriverClient({ url: first.owner.server.url }); + const secondClient = createDesktopDriverClient({ url: second.owner.server.url }); + expect(await firstClient.status()).toMatchObject({ targets: [{ id: 'external-first', launchModes: ['attach'] }] }); + const capabilities = (id: string) => ({ + alwaysMatch: { platformName: 'macos', 'furn:target': id, 'furn:launchMode': 'attach' }, + }); + const session = await firstClient.newSession(capabilities('external-first')); + const windows = await session.getWindowHandles(); + await expect(first.host.inspectApplication({ ...target, host: first.host })).resolves.toMatchObject({ + processId: identity.processId, + processStartedAt: identity.processStartedAt, + }); + await expect(second.host.inspectApplication({ ...target, host: second.host })).resolves.toMatchObject({ + processId: identity.processId, + processStartedAt: identity.processStartedAt, + }); + await expect(secondClient.newSession(capabilities('external-second'))).rejects.toThrow(/Another desktop service controls/); + await expect(session.getWindowHandles()).resolves.toEqual(windows); + await expect(stale.attach({ ...target, host: stale })).rejects.toThrow(/incarnation/); + await expect(probe(identity)).resolves.toBe('running'); + await session.delete(); + const replacement = await secondClient.newSession(capabilities('external-second')); + expect(second.owner.server.sessions.get(replacement.id).lease.processId).toBe(identity.processId); + await replacement.delete(); + await first.owner.close(); + await second.owner.close(); + await expect(probe(identity)).resolves.toBe('running'); + evidence.push({ case: 'external-preservation-exact-service-exclusion-stale-identity', state: 'passed', ...identity }); + } finally { + await cleanupExternalFixture(child, identity, [ + first?.owner.close(), + second?.owner.close(), + stale?.dispose(), + first?.reporter.finish(), + second?.reporter.finish(), + ]); + } +}); + +async function cleanupExternalFixture( + child: ChildProcess, + identity: DesktopApplicationIdentity | undefined, + work: readonly (Promise | undefined)[], +): Promise { + const cleanup = await Promise.allSettled(work); + const failures = cleanup.flatMap((result) => (result.status === 'rejected' ? [result.reason] : [])); + try { + await stopFixtureChild(child, identity); + } catch (error) { + failures.push(error); + } + if (failures.length) throw new AggregateError(failures, 'External fixture cleanup failed.'); +} + +async function stopFixtureChild(child: ChildProcess, identity?: DesktopApplicationIdentity): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + if (!identity) throw new Error(`Fixture ${child.pid} has no verified identity; refusing a guessed cleanup.`); + const probe = createMacOSApplicationProbe({ artifact }); + for (const signal of ['SIGTERM', 'SIGKILL'] as const) { + const state = await probe(identity); + if (state === 'unknown') throw new Error(`Fixture ${child.pid} has an unreadable incarnation; refusing to signal it.`); + if (state === 'running') child.kill(signal); + if (await waitForChildExit(child)) return; + } + throw new Error(`Fixture ${child.pid} did not exit after exact cleanup.`); +} + +function waitForChildExit(child: ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true); + return new Promise((resolve) => { + const onExit = () => { + clearTimeout(timer); + resolve(true); + }; + const timer = setTimeout(() => { + child.off('exit', onExit); + resolve(false); + }, 5_000); + child.once('exit', onExit); + }); +} + +nativeTest('bare executable launches are direct children, not LaunchServices ownership', async () => { + const executable = path.join(directory, 'direct-child-fixture'); + fs.copyFileSync(fixture.executable, executable); + fs.chmodSync(executable, 0o700); + const records: DesktopResourceRecord[] = []; + const underlying = nativeHost({ executablePath: executable, windowTitle: fixture.windowTitle }); + const host = await createDesktopApplicationHost(underlying, { + target, + lifetime: 'session', + resourcePrefix: 'direct', + onResource: (record) => records.push(record), + }); + let lease: ApplicationLease | undefined; + try { + lease = await host.launch({ ...target, host }); + expect(lease).toMatchObject({ ownership: 'launched', launchKind: 'direct-child', containment: 'uncontained' }); + } finally { + await host.dispose(); + } + expect(records.at(-1)).toMatchObject({ disposal: 'settled', application: { launchKind: 'direct-child' } }); + await expect(createMacOSApplicationProbe({ artifact })(captureDesktopApplicationIdentity(lease!, 'macos'))).resolves.toBe('exited'); + evidence.push({ case: 'direct-child-causal-ownership', state: 'passed' }); +}); + +nativeTest('failed acquired-record publication retains the durable pending claim until exact cleanup', async () => { + const root = path.join(directory, 'publication-failure'); + const claim = createDesktopWorkloadClaim({ + directory: root, + runId: 'publication-failure', + targetId: target.id, + targetInstanceId: target.id, + processIdentity: await describeDesktopWorkloadOwner({ helperPath: artifact.executablePath }), + }); + let identity: DesktopApplicationIdentity | undefined; + let refusePublication = true; + const host = await createDesktopApplicationHost(nativeHost(application()), { + target, + lifetime: 'session', + resourcePrefix: 'publication-failure', + onResource: (resource) => { + if (resource.application?.processId) { + identity = captureDesktopApplicationIdentity( + { + id: resource.application.leaseId!, + ownership: 'launched', + processId: resource.application.processId, + processStartedAt: resource.application.processStartedAt, + }, + 'macos', + ); + if (refusePublication) throw new Error('Injected acquired-record publication failure.'); + } + claim.updateResource(resource); + }, + }); + try { + await expect(host.launch({ ...target, host })).rejects.toThrow(/publication failed/); + expect(identity).toBeDefined(); + await expect(createMacOSApplicationProbe({ artifact })(identity!)).resolves.toBe('running'); + const persisted = JSON.parse(fs.readFileSync(path.join(root, 'interactive.lock'), 'utf8')); + expect(persisted).toMatchObject({ + state: 'fenced', + resources: [expect.objectContaining({ readiness: 'pending', disposal: 'pending' })], + }); + expect(() => claim.release()).toThrow(/unresolved effects/); + expect(() => + createDesktopWorkloadClaim({ directory: root, runId: 'foreign', targetId: target.id, targetInstanceId: target.id }), + ).toThrow(/Another desktop workload/); + } finally { + refusePublication = false; + await host.dispose(); + claim.release(); + } + await expect(createMacOSApplicationProbe({ artifact })(identity!)).resolves.toBe('exited'); + evidence.push({ case: 'native-acquired-publication-failure', state: 'passed', ...identity }); +}); + +for (const phase of ['pending', 'acquired'] as const) { + nativeTest(`owner death with ${phase} publication preserves native survivors and fences replacement`, async () => { + const root = path.join(directory, `owner-death-${phase}`); + fs.mkdirSync(root); + const configurationFile = path.join(root, 'configuration.json'); + const descendantFile = path.join(root, 'descendant.json'); + fs.writeFileSync( + configurationFile, + JSON.stringify({ + artifact, + application: application({ arguments: ['--descendant-ready-file', descendantFile] }), + directory: root, + }), + ); + const child = spawn( + process.execPath, + [path.resolve(__dirname, '../fixtures/application-ownership/crash-owner.mjs'), configurationFile, phase], + { stdio: 'ignore' }, + ); + const probe = createMacOSApplicationProbe({ artifact }); + let ownerIdentity: DesktopApplicationIdentity | undefined; + let helperIdentity: DesktopApplicationIdentity | undefined; + let appIdentity: DesktopApplicationIdentity | undefined; + let descendantIdentity: DesktopApplicationIdentity | undefined; + let claimContents: string | undefined; + const claimFile = path.join(root, 'interactive.lock'); + const identify = async (pid: number, executable: string): Promise => { + const observed = await describeMacOSService({ artifact, pid }); + expect(fs.realpathSync(observed.executablePath)).toBe(fs.realpathSync(executable)); + return captureDesktopApplicationIdentity( + { id: 'fixture-process', ownership: 'attached', processId: pid, processStartedAt: observed.startedAt }, + 'macos', + ); + }; + try { + await once(child, 'spawn'); + ownerIdentity = await identify(child.pid!, process.execPath); + await waitForFixtureFile(path.join(root, 'acquired.json'), child); + const acquired = JSON.parse(fs.readFileSync(path.join(root, 'acquired.json'), 'utf8')); + expect(acquired.owner).toMatchObject({ pid: ownerIdentity.processId, startedAt: ownerIdentity.processStartedAt }); + appIdentity = await identify(acquired.resource.application.processId, fixture.executable); + expect(acquired.resource.application).toMatchObject({ + processStartedAt: appIdentity.processStartedAt, + containment: 'uncontained', + launchKind: 'launch-services', + }); + const children = spawnSync('/usr/bin/pgrep', ['-P', String(child.pid)], { encoding: 'utf8', timeout: 5000 }); + expect(children.status).toBe(0); + const helperPids = children.stdout.trim().split(/\s+/).map(Number); + expect(helperPids).toHaveLength(1); + helperIdentity = await identify(helperPids[0], artifact.executablePath); + await waitForFixtureFile(descendantFile, child); + const descendant = JSON.parse(fs.readFileSync(descendantFile, 'utf8')); + expect(descendant.parentProcessId).toBe(appIdentity.processId); + descendantIdentity = await identify(descendant.processId, '/bin/sleep'); + const parent = spawnSync('/bin/ps', ['-p', String(descendant.processId), '-o', 'ppid='], { encoding: 'utf8', timeout: 5000 }); + expect(Number(parent.stdout.trim())).toBe(appIdentity.processId); + claimContents = fs.readFileSync(claimFile, 'utf8'); + expect(JSON.parse(claimContents)).toMatchObject({ + state: 'fenced', + resources: [expect.objectContaining({ readiness: phase === 'pending' ? 'pending' : 'ready', disposal: 'pending' })], + }); + if (phase === 'pending') expect(JSON.parse(claimContents).resources[0].application.processId).toBeUndefined(); + else expect(JSON.parse(claimContents).resources[0].application.processStartedAt).toBe(appIdentity.processStartedAt); + await expect(probe(ownerIdentity)).resolves.toBe('running'); + child.kill('SIGKILL'); + expect(await waitForChildExit(child)).toBe(true); + await waitForExactExit(helperIdentity); + await expect(probe(appIdentity)).resolves.toBe('running'); + await expect(probe(descendantIdentity)).resolves.toBe('running'); + await expect( + inspectDesktopWorkloadClaim(claimFile, async () => ({ state: (await probe(ownerIdentity!)) === 'exited' ? 'exited' : 'unknown' })), + ).resolves.toMatchObject({ state: 'exited', record: { state: 'fenced' } }); + expect(() => + createDesktopWorkloadClaim({ directory: root, runId: 'replacement', targetId: target.id, targetInstanceId: target.id }), + ).toThrow(/Another desktop workload/); + expect(fs.readFileSync(claimFile, 'utf8')).toBe(claimContents); + const observer = nativeHost(desktopApplicationIdentityDescriptor(appIdentity, application())); + try { + await expect(observer.inspectApplication({ ...target, host: observer })).resolves.toMatchObject({ + processId: appIdentity.processId, + processStartedAt: appIdentity.processStartedAt, + }); + } finally { + await observer.dispose(); + } + await stopExactFixtureProcess(appIdentity); + await expect(probe(descendantIdentity)).resolves.toBe('running'); + evidence.push({ + case: `owner-death-${phase}-publication`, + state: 'passed', + ownerIdentity, + helperIdentity, + appIdentity, + descendantIdentity, + descendantSurvivedPrimaryExit: true, + automaticRecovery: false, + }); + } finally { + if (ownerIdentity) await stopFixtureChild(child, ownerIdentity); + if (helperIdentity) await stopExactFixtureProcess(helperIdentity); + if (appIdentity) await stopExactFixtureProcess(appIdentity); + if (descendantIdentity) await stopExactFixtureProcess(descendantIdentity); + if (claimContents && fs.readFileSync(claimFile, 'utf8') === claimContents) { + // Only this fixture's recorded owner, helper, app and descendant have been proven exited. + fs.unlinkSync(claimFile); + evidence.push({ case: `owner-death-${phase}-operator-cleanup`, state: 'passed' }); + } + } + }); +} + +async function waitForFixtureFile(filename: string, child: ChildProcess): Promise { + const deadline = Date.now() + 30_000; + while (!fs.existsSync(filename)) { + if (child.exitCode !== null || child.signalCode !== null) throw new Error(`Fixture owner exited before ${path.basename(filename)}.`); + if (Date.now() >= deadline) throw new Error(`Fixture owner did not publish ${path.basename(filename)}.`); + await new Promise((resolve) => setTimeout(resolve, 50)); + } +} + +async function waitForExactExit(identity: DesktopApplicationIdentity): Promise { + const probe = createMacOSApplicationProbe({ artifact }); + const deadline = Date.now() + 10_000; + while ((await probe(identity)) !== 'exited') { + if (Date.now() >= deadline) throw new Error(`Fixture ${identity.processId} did not prove exact exit.`); + await new Promise((resolve) => setTimeout(resolve, 50)); + } +} + +async function stopExactFixtureProcess(identity: DesktopApplicationIdentity): Promise { + const probe = createMacOSApplicationProbe({ artifact }); + const state = await probe(identity); + if (state === 'unknown') throw new Error(`Fixture ${identity.processId} has an unreadable incarnation; refusing cleanup.`); + if (state === 'running') process.kill(identity.processId, 'SIGKILL'); + await waitForExactExit(identity); +} + +nativeTest('post-launch cancellation journals the exact unleased process and its settled exit', async () => { + const ready = path.join(directory, 'cancelled.ready'); + const records: DesktopResourceRecord[] = []; + const underlying = nativeHost(application({ windowTitle: 'Deliberately missing title', arguments: ['--ready-file', ready] })); + const host = await createDesktopApplicationHost(underlying, { + target, + lifetime: 'session', + resourcePrefix: 'cancelled', + onResource: (record) => records.push(record), + }); + const controller = new AbortController(); + const launching = host.launch({ ...target, host }, controller.signal).catch((error: unknown) => error); + try { + const deadline = Date.now() + 10_000; + while (!fs.existsSync(ready)) { + if (Date.now() >= deadline) throw new Error('The application did not publish post-launch readiness.'); + await new Promise((resolve) => setTimeout(resolve, 25)); + } + controller.abort(); + expect(await launching).toMatchObject({ name: 'AbortError', data: { applicationSettlement: 'exited' } }); + const record = records.at(-1)!; + expect(record).toMatchObject({ + readiness: 'failed', + disposal: 'settled', + application: { processId: Number(fs.readFileSync(ready, 'utf8')), launchKind: 'launch-services' }, + }); + await expect( + createMacOSApplicationProbe({ artifact })({ + processId: record.application!.processId!, + processStartedAt: record.application!.processStartedAt!, + }), + ).resolves.toBe('exited'); + evidence.push({ case: 'cancelled-unleased-process-journal', state: 'passed', application: record.application }); + } finally { + controller.abort(); + await launching; + await host.dispose(); + } +}); + +for (const launchKind of ['direct-child', 'launch-services'] as const) { + nativeTest(`failed native ${launchKind} launch records exact primary-process cleanup evidence`, async () => { + const executable = path.join(directory, 'failed-direct-fixture'); + if (launchKind === 'direct-child') { + fs.copyFileSync(fixture.executable, executable); + fs.chmodSync(executable, 0o700); + } + const records: DesktopResourceRecord[] = []; + const host = await createDesktopApplicationHost( + nativeHost( + launchKind === 'direct-child' + ? { executablePath: executable, windowTitle: 'Deliberately missing title' } + : application({ windowTitle: 'Deliberately missing title' }), + ), + { + target, + lifetime: 'session', + resourcePrefix: `failed-${launchKind}`, + onResource: (record) => records.push(record), + }, + ); + await withCleanup( + async () => { + const failure = await host.launch({ ...target, host }).catch((error: unknown) => error); + evidence.push({ case: `rejected-${launchKind}-launch`, state: 'observed', failure: errorDetails(failure), records }); + expect(failure).toMatchObject({ code: 'launch-failed', data: { applicationSettlement: 'exited', launchKind } }); + const record = records.at(-1)!; + expect(record).toMatchObject({ readiness: 'failed', disposal: 'settled', application: { launchKind } }); + const identity = captureDesktopApplicationIdentity( + { + id: 'failed-launch', + ownership: 'launched', + processId: record.application!.processId, + processStartedAt: record.application!.processStartedAt, + }, + 'macos', + ); + await expect(createMacOSApplicationProbe({ artifact })(identity)).resolves.toBe('exited'); + evidence.push({ + case: `failed-${launchKind}-exact-cleanup`, + state: 'passed', + application: record.application, + failure: errorDetails(failure), + }); + }, + () => host.dispose(), + ); + }); +} + +recordNativeCase( + 'two independent helpers serialize physical input across different applications', + async () => { + const first = nativeHost(application({ windowTitle: 'Input mutex A', arguments: ['--title', 'Input mutex A'] })); + const second = nativeHost(application({ windowTitle: 'Input mutex B', arguments: ['--title', 'Input mutex B'] })); + try { + expect((await first.probe()).features.keyboard).toBe(true); + expect((await second.probe()).features.keyboard).toBe(true); + await first.launch({ ...target, host: first }); + await second.launch({ ...target, host: second }); + const down = [{ type: 'key' as const, id: 'mutex', actions: [{ type: 'keyDown' as const, value: '\uE008' }] }]; + await first.performActions(down); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 500); + try { + await expect(second.performActions(down, controller.signal)).rejects.toMatchObject({ name: 'AbortError' }); + } finally { + clearTimeout(timeout); + await first.releaseActions(); + } + await second.performActions([{ ...down[0], actions: [...down[0].actions, { type: 'keyUp', value: '\uE008' }] }]); + evidence.push({ case: 'cross-helper-physical-input-mutex', state: 'passed' }); + } finally { + await Promise.all([first.releaseActions(), second.releaseActions()]); + await Promise.all([first.dispose(), second.dispose()]); + } + }, + true, +); diff --git a/packages/agentic/desktop-test/src/applicationOwnership.test.ts b/packages/agentic/desktop-test/src/applicationOwnership.test.ts new file mode 100644 index 0000000000..6abb06f3d5 --- /dev/null +++ b/packages/agentic/desktop-test/src/applicationOwnership.test.ts @@ -0,0 +1,383 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +import type { ApplicationLease, DesktopHost, DesktopTarget } from '@fluentui-react-native/desktop-driver'; + +import { + captureDesktopApplicationIdentity, + createDesktopApplicationHost, + desktopApplicationIdentityDescriptor, +} from './applicationOwnership'; +import type { DesktopResourceRecord } from './resources'; +import { createDesktopWorkloadClaim } from './ownership'; + +const owned: ApplicationLease = { + id: 'lease-1', + ownership: 'launched', + processId: 123, + processStartedAt: '2026-11-12T00:00:00.123456Z', + containment: 'uncontained', + launchKind: 'launch-services', +}; +const target = { endpoint: 'macos', id: 'fixture', launchModes: ['launch'], platformName: 'macos' } satisfies Omit; +type DesktopHostEvent = Parameters>[0]>[0]; + +function fixture() { + const records: DesktopResourceRecord[] = []; + let listener: ((event: DesktopHostEvent) => void) | undefined; + const native = { + endpoint: 'macos', + probe: jest.fn(async () => ({})), + launch: jest.fn(async () => ({ ...owned })), + attach: jest.fn(async () => ({ ...owned, ownership: 'attached' as const, containment: 'external' as const })), + windows: jest.fn(async () => []), + closeApplication: jest.fn(async () => undefined), + dispose: jest.fn(async () => undefined), + subscribe: jest.fn((callback: (event: DesktopHostEvent) => void) => { + listener = callback; + return () => undefined; + }), + }; + const host = native as unknown as DesktopHost; + return { + native, + host, + records, + options: { target, resourcePrefix: 'run:target', onResource: (record: DesktopResourceRecord) => records.push(record) }, + event: (event: DesktopHostEvent) => listener?.(event), + }; +} + +describe('neutral desktop application ownership', () => { + test('the crash-owner fixture retires nonzero when its parent never reaches cleanup', () => { + const result = spawnSync( + process.execPath, + [path.resolve(__dirname, '../fixtures/application-ownership/crash-owner.mjs'), '--retirement-probe'], + { encoding: 'utf8', timeout: 5000 }, + ); + expect(result.error).toBeUndefined(); + expect(result.signal).toBeNull(); + expect(result.status).toBe(1); + expect(result.stderr).toBe('The native crash-owner retirement deadline expired.\n'); + }); + + test('publishes the possible launch before dispatch and journals the acquired exact process', async () => { + const f = fixture(); + f.native.launch.mockImplementationOnce(async () => { + expect(f.records).toHaveLength(1); + expect(f.records[0]).toMatchObject({ readiness: 'pending', disposal: 'pending', application: { lifetime: 'session' } }); + return { ...owned }; + }); + const host = await createDesktopApplicationHost(f.host, { ...f.options, lifetime: 'session' }); + const lease = await host.launch({ ...target, host }); + expect(f.records.at(-1)).toMatchObject({ + readiness: 'ready', + ownership: 'owned', + application: { + processId: owned.processId, + processStartedAt: owned.processStartedAt, + launchKind: 'launch-services', + containment: 'uncontained', + }, + }); + await host.closeApplication(lease); + expect(f.native.closeApplication).toHaveBeenCalledWith(owned, undefined); + expect(f.records.at(-1)?.disposal).toBe('settled'); + await host.dispose(); + expect(f.native.closeApplication).toHaveBeenCalledTimes(1); + }); + + test('supervisor owns one launch across sessions, while sessions receive only borrowed leases', async () => { + const f = fixture(); + const host = await createDesktopApplicationHost(f.host, { ...f.options, lifetime: 'supervisor' }); + const first = await host.launch({ ...target, host }); + await host.closeApplication(first); + const second = await host.launch({ ...target, host }); + expect(first).toMatchObject({ id: owned.id, ownership: 'attached' }); + expect(second).toEqual(first); + expect(f.native.launch).toHaveBeenCalledTimes(1); + expect(f.native.closeApplication).not.toHaveBeenCalled(); + expect(f.records.at(-1)?.disposal).toBe('pending'); + await host.dispose(); + expect(f.native.closeApplication).toHaveBeenCalledTimes(1); + expect(f.native.closeApplication).toHaveBeenCalledWith(owned, undefined); + expect(f.records.at(-1)).toMatchObject({ disposal: 'settled', application: { lifetime: 'supervisor' } }); + }); + + test('attached applications remain borrowed during session close and supervisor cleanup', async () => { + const f = fixture(); + const host = await createDesktopApplicationHost(f.host, { ...f.options, lifetime: 'external' }); + await expect(host.launch({ ...target, host })).rejects.toThrow(/only be attached/); + const lease = await host.attach({ ...target, host }); + expect(f.records.at(-1)).toMatchObject({ ownership: 'borrowed', disposal: 'not-owned', application: { lifetime: 'external' } }); + await host.dispose(); + expect(f.native.closeApplication).toHaveBeenCalledWith({ ...owned, ownership: 'attached', containment: 'external' }, undefined); + expect(f.native.launch).not.toHaveBeenCalled(); + expect(lease.ownership).toBe('attached'); + }); + + test('stale and foreign leases cannot close the acquired application; events are advisory', async () => { + const f = fixture(); + const host = await createDesktopApplicationHost(f.host, { ...f.options, lifetime: 'session' }); + const lease = await host.launch({ ...target, host }); + await expect(host.closeApplication({ ...lease, processStartedAt: '2026-11-12T00:00:00.123457Z' })).rejects.toThrow(/foreign, stale/); + await expect(host.closeApplication({ ...lease, id: 'foreign' })).rejects.toThrow(/foreign, stale/); + host.subscribe?.(() => undefined); + f.event({ type: 'application-exited', applicationId: owned.id }); + f.event({ type: 'application-exited', applicationId: 'foreign' }); + expect(f.records.at(-1)?.disposal).toBe('pending'); + expect(f.native.closeApplication).not.toHaveBeenCalled(); + await host.dispose(); + }); + + test('a failed unleased launch names its survivor and remains unsettled after helper disposal', async () => { + const f = fixture(); + const error = Object.assign(new Error('survivor'), { + data: { + processId: owned.processId, + processStartedAt: owned.processStartedAt, + launchKind: 'launch-services', + applicationSettlement: 'unknown', + }, + }); + f.native.launch.mockRejectedValueOnce(error); + const host = await createDesktopApplicationHost(f.host, { ...f.options, lifetime: 'session' }); + await expect(host.launch({ ...target, host })).rejects.toThrow('survivor'); + expect(f.records.at(-1)).toMatchObject({ + readiness: 'failed', + disposal: 'failed', + application: { processId: owned.processId, processStartedAt: owned.processStartedAt }, + }); + await expect(host.dispose()).rejects.toThrow(/did not settle/); + expect(f.records.at(-1)?.disposal).toBe('failed'); + }); + + test.each(['macos', 'windows', 'win32'] as const)( + 'a no-data %s launch rejection releases the claim for the next run', + async (endpoint) => { + const directory = fs.mkdtempSync(path.join(process.cwd(), '.application-claim-test-')); + const claimOptions = { directory, runId: 'failed-launch', targetId: 'fixture', targetInstanceId: 'fixture' }; + try { + const claim = createDesktopWorkloadClaim(claimOptions); + const f = fixture(); + f.native.endpoint = endpoint; + f.native.launch.mockRejectedValueOnce(new Error('launch rejected before creating an application')); + const configured = { + ...target, + endpoint, + platformName: endpoint === 'macos' ? 'macos' : 'windows', + } satisfies Omit; + const host = await createDesktopApplicationHost(f.host, { + ...f.options, + target: configured, + lifetime: 'session', + onResource: (record) => claim.updateResource(record), + }); + await expect(host.launch({ ...configured, host })).rejects.toThrow('launch rejected'); + await host.dispose(); + expect(claim.record).toMatchObject({ + state: 'active', + resources: [expect.objectContaining({ readiness: 'failed', disposal: 'settled' })], + unresolvedEffects: [], + }); + claim.release(); + expect(fs.existsSync(path.join(directory, 'interactive.lock'))).toBe(false); + const next = createDesktopWorkloadClaim({ ...claimOptions, runId: 'next-run' }); + next.release(); + } finally { + fs.rmSync(directory, { force: true, recursive: true }); + } + }, + ); + + test('an explicit not-started verdict settles an otherwise indeterminate launch rejection', async () => { + const f = fixture(); + f.native.launch.mockRejectedValueOnce( + Object.assign(new Error('no installed application'), { + effect: 'unknown', + data: { applicationSettlement: 'not-started' }, + }), + ); + const host = await createDesktopApplicationHost(f.host, { ...f.options, lifetime: 'session' }); + await expect(host.launch({ ...target, host })).rejects.toThrow('no installed application'); + expect(f.records.at(-1)?.disposal).toBe('settled'); + await host.dispose(); + }); + + test.each([ + { applicationSettlement: 'unknown' }, + { applicationSettlement: 'unrecognized-verdict' }, + { processId: owned.processId }, + { processStartedAt: owned.processStartedAt }, + { processId: owned.processId, applicationSettlement: 'not-started' }, + ])('a launch that may have created a process remains fenced with evidence %p', async (data) => { + const f = fixture(); + f.native.launch.mockRejectedValueOnce(Object.assign(new Error('launch interrupted'), { data })); + const host = await createDesktopApplicationHost(f.host, { ...f.options, lifetime: 'session' }); + await expect(host.launch({ ...target, host })).rejects.toThrow('launch interrupted'); + await expect(host.dispose()).rejects.toThrow(/did not settle/); + expect(f.records.at(-1)?.disposal).toBe('failed'); + }); + + test('a launch that succeeded after cancellation remains uncertain without a returned lease', async () => { + const f = fixture(); + f.native.launch.mockRejectedValueOnce(Object.assign(new Error('completed after cancellation'), { effect: 'applied' })); + const host = await createDesktopApplicationHost(f.host, { ...f.options, lifetime: 'session' }); + await expect(host.launch({ ...target, host })).rejects.toThrow('completed after cancellation'); + await expect(host.dispose()).rejects.toThrow(/did not settle/); + }); + + test.each(['windows', 'win32'] as const)('names an uncontained %s application in the durable failed-cleanup claim', async (endpoint) => { + const directory = fs.mkdtempSync(path.join(process.cwd(), '.application-claim-test-')); + try { + const claim = createDesktopWorkloadClaim({ + directory, + runId: 'survivor', + targetId: 'packaged', + targetInstanceId: 'packaged', + }); + const f = fixture(); + f.native.endpoint = endpoint; + f.native.launch.mockResolvedValueOnce({ + id: 'uncontained-package', + ownership: 'launched', + processId: 456, + processStartedAt: '2026-11-12T00:00:00.1234567Z', + containment: 'uncontained', + }); + f.native.closeApplication.mockRejectedValue(new Error('packaged survivor')); + const configured = { ...target, endpoint, platformName: 'windows' } satisfies Omit; + const host = await createDesktopApplicationHost(f.host, { + ...f.options, + target: configured, + lifetime: 'session', + onResource: (record) => claim.updateResource(record), + }); + await host.launch({ ...configured, host }); + await expect(host.dispose()).rejects.toThrow(/did not settle/); + const persisted = JSON.parse(fs.readFileSync(path.join(directory, 'interactive.lock'), 'utf8')); + expect(persisted).toMatchObject({ + state: 'fenced', + resources: [ + expect.objectContaining({ + disposal: 'failed', + application: { + endpoint, + lifetime: 'session', + leaseId: 'uncontained-package', + processId: 456, + processStartedAt: '2026-11-12T00:00:00.1234567Z', + containment: 'uncontained', + }, + }), + ], + unresolvedEffects: [expect.objectContaining({ resourceIdentity: persisted.resources[0].identity })], + }); + expect(() => claim.release()).toThrow(/unresolved/); + } finally { + fs.rmSync(directory, { force: true, recursive: true }); + } + }); + + test('late acquisition is drained and exactly closed before owner disposal settles', async () => { + const f = fixture(); + let finish!: (lease: ApplicationLease) => void; + f.native.launch.mockImplementationOnce(() => new Promise((resolve) => (finish = resolve))); + const host = await createDesktopApplicationHost(f.host, { ...f.options, lifetime: 'session' }); + const acquisition = host.launch({ ...target, host }); + const disposal = host.dispose(); + expect(f.native.dispose).not.toHaveBeenCalled(); + finish(owned); + await acquisition; + await disposal; + expect(f.native.closeApplication).toHaveBeenCalledWith(owned, undefined); + expect(f.records.at(-1)?.disposal).toBe('settled'); + }); + + test('publication failure after launch still disposes the exact owned lease', async () => { + const f = fixture(); + let publications = 0; + const host = await createDesktopApplicationHost(f.host, { + ...f.options, + lifetime: 'session', + onResource(record) { + f.records.push(record); + if (++publications > 1) throw new Error('claim publication failed'); + }, + }); + await expect(host.launch({ ...target, host })).rejects.toThrow(/publication failed/); + await expect(host.dispose()).rejects.toThrow(/did not settle/); + expect(f.native.closeApplication).toHaveBeenCalledWith(owned, undefined); + expect(f.native.dispose).toHaveBeenCalledTimes(1); + }); + + test('a failed pending publication prevents native launch dispatch', async () => { + const f = fixture(); + const host = await createDesktopApplicationHost(f.host, { + ...f.options, + lifetime: 'session', + onResource() { + throw new Error('claim unavailable'); + }, + }); + await expect(host.launch({ ...target, host })).rejects.toThrow('claim unavailable'); + expect(f.native.launch).not.toHaveBeenCalled(); + await host.dispose(); + }); + + test('a cancelled launch can settle only with exact provider exit evidence', async () => { + const f = fixture(); + f.native.launch.mockRejectedValueOnce( + Object.assign(new Error('cancelled'), { + name: 'AbortError', + data: { processId: owned.processId, processStartedAt: owned.processStartedAt, applicationSettlement: 'exited' }, + }), + ); + const host = await createDesktopApplicationHost(f.host, { ...f.options, lifetime: 'session' }); + await expect(host.launch({ ...target, host })).rejects.toMatchObject({ name: 'AbortError' }); + expect(f.records.at(-1)).toMatchObject({ readiness: 'failed', disposal: 'settled', application: { processId: owned.processId } }); + await host.dispose(); + expect(f.native.launch).toHaveBeenCalledTimes(1); + }); + + test('failed close retains the acquired identity and refuses to report settled disposal', async () => { + const f = fixture(); + f.native.closeApplication.mockRejectedValue(new Error('permission refused')); + const host = await createDesktopApplicationHost(f.host, { ...f.options, lifetime: 'session' }); + const lease = await host.launch({ ...target, host }); + await expect(host.closeApplication(lease)).rejects.toThrow('permission refused'); + await expect(host.dispose()).rejects.toThrow(/did not settle/); + expect(f.records.at(-1)).toMatchObject({ + disposal: 'failed', + application: { processId: owned.processId, processStartedAt: owned.processStartedAt }, + }); + }); + + test('duplicate lease identifiers keep both named resources and never authorize a guessed close', async () => { + const f = fixture(); + const host = await createDesktopApplicationHost(f.host, { ...f.options, lifetime: 'session' }); + const first = await host.launch({ ...target, host }); + f.native.launch.mockResolvedValueOnce({ ...owned, processId: 456 }); + await expect(host.launch({ ...target, host })).rejects.toThrow(/ambiguous/); + await expect(host.closeApplication(first)).rejects.toThrow(/foreign, stale/); + expect(new Set(f.records.map((record) => record.id)).size).toBe(2); + await expect(host.dispose()).rejects.toThrow(/did not settle/); + expect(f.native.closeApplication).not.toHaveBeenCalled(); + }); + + test('neutral exact attach rejects legacy timestamp precision and never grants launch authority', () => { + const identity = captureDesktopApplicationIdentity(owned, 'macos'); + expect(desktopApplicationIdentityDescriptor(identity, { windowTitle: 'Fixture' })).toEqual({ + windowTitle: 'Fixture', + processIdentity: { processId: owned.processId, processStartedAt: owned.processStartedAt }, + }); + expect(() => captureDesktopApplicationIdentity({ ...owned, processStartedAt: '2026-11-12T00:00:00.123Z' }, 'macos')).toThrow( + /microsecond/, + ); + expect(() => captureDesktopApplicationIdentity({ ...owned, processId: 0x80000000 }, 'macos')).toThrow(/native PID/); + expect(() => + desktopApplicationIdentityDescriptor(identity, { windowTitle: 'Fixture', leasePath: 'old', leaseNonce: 'secret' }), + ).toThrow(/legacy lease/); + }); +}); diff --git a/packages/agentic/desktop-test/src/applicationOwnership.ts b/packages/agentic/desktop-test/src/applicationOwnership.ts new file mode 100644 index 0000000000..80df1f88d0 --- /dev/null +++ b/packages/agentic/desktop-test/src/applicationOwnership.ts @@ -0,0 +1,259 @@ +import { randomUUID } from 'node:crypto'; + +import type { + ApplicationLease, + DesktopHost, + DesktopTarget, + NativeDesktopApplicationDescriptor, +} from '@fluentui-react-native/desktop-driver'; +import type { DesktopApplicationResource, DesktopResourceRecord } from './resources.js'; + +export type DesktopApplicationIdentity = Readonly<{ + schemaVersion: 1; + endpoint: 'macos'; + processId: number; + processStartedAt: string; +}>; + +/** Captures the native kernel identity. Legacy millisecond/LaunchServices lease timestamps cannot be upgraded. */ +export function captureDesktopApplicationIdentity(lease: ApplicationLease, endpoint: 'macos'): DesktopApplicationIdentity { + const identity = { schemaVersion: 1 as const, endpoint, processId: lease.processId, processStartedAt: lease.processStartedAt }; + validateIdentity(identity); + return Object.freeze(identity); +} + +/** Selects an exact external incarnation without transferring ownership or consulting a Storybook lease. */ +export function desktopApplicationIdentityDescriptor( + identity: DesktopApplicationIdentity, + application: NativeDesktopApplicationDescriptor, +): NativeDesktopApplicationDescriptor { + validateIdentity(identity); + if (application.leasePath || application.leaseNonce) + throw new TypeError('An exact application identity cannot be combined with a legacy lease.'); + if (!application.windowTitle) throw new TypeError('An exact application attach requires a window title.'); + return Object.freeze({ + ...application, + processIdentity: Object.freeze({ processId: identity.processId, processStartedAt: identity.processStartedAt }), + }); +} + +export type DesktopApplicationHostOptions = { + target: Omit; + lifetime: 'session' | 'supervisor' | 'external'; + resourcePrefix: string; + /** Synchronous durable publication: failure prevents dispatch or fences an already dispatched acquisition. */ + onResource?: (resource: DesktopResourceRecord) => void; +}; + +type Entry = { record: DesktopResourceRecord; lease?: ApplicationLease; uncertain: boolean }; + +/** + * Keeps application lifetime independent of WebDriver session lifetime. The native host remains the only authority + * allowed to signal an application; an advisory exit notification never settles a resource. + */ +export async function createDesktopApplicationHost(host: DesktopHost, options: DesktopApplicationHostOptions): Promise { + if (options.lifetime === 'supervisor' && options.target.endpoint !== 'macos') + throw new TypeError('Supervisor-owned application launch is currently supported only on macOS.'); + const entries = new Map(); + const inFlight = new Set>(); + let retained: ApplicationLease | undefined; + let closing: Promise | undefined; + let generation = 0; + const publish = (entry: Entry, fields: Partial = {}) => { + entry.record = Object.freeze({ ...entry.record, ...fields }); + options.onResource?.(entry.record); + }; + const requireEntry = (lease: ApplicationLease): Entry => { + const matches = [...entries.values()].filter((entry) => entry.lease?.id === lease.id); + const entry = matches.length === 1 ? matches[0] : undefined; + if ( + !entry?.lease || + entry.lease.processId !== lease.processId || + entry.lease.processStartedAt !== lease.processStartedAt || + entry.record.disposal === 'settled' + ) { + throw new Error('The application lease is foreign, stale, or already released.'); + } + return entry; + }; + const acquire = async (target: DesktopTarget, launch: boolean, signal?: AbortSignal): Promise => { + if (closing) throw new Error('The application owner is closing.'); + signal?.throwIfAborted(); + const owns = launch; + const entry: Entry = { + record: Object.freeze({ + id: `${options.resourcePrefix}:application:${++generation}`, + identity: `application-acquisition:${randomUUID()}`, + generation, + ownership: owns ? 'owned' : 'borrowed', + readiness: 'pending', + disposal: owns ? 'pending' : 'not-owned', + application: Object.freeze({ endpoint: target.endpoint, lifetime: owns ? options.lifetime : 'external' }), + }), + uncertain: owns, + }; + // Register the possibility of a launch before the provider may create a process. + publish(entry); + entries.set(entry.record.id, entry); + try { + const lease = await (launch ? host.launch(target, signal) : host.attach(target, signal)); + entry.lease = Object.freeze({ ...lease }); + if (launch && lease.ownership !== 'launched') throw new Error('A launch did not return an owned application lease.'); + if (!launch && lease.ownership !== 'attached') throw new Error('Attach attempted to transfer application ownership.'); + const application: DesktopApplicationResource = Object.freeze({ + endpoint: target.endpoint, + lifetime: launch ? options.lifetime : 'external', + leaseId: lease.id, + ...(lease.processId === undefined ? {} : { processId: lease.processId }), + ...(lease.processStartedAt === undefined ? {} : { processStartedAt: lease.processStartedAt }), + ...(lease.containment === undefined ? {} : { containment: lease.containment }), + ...(lease.launchKind === undefined ? {} : { launchKind: lease.launchKind }), + }); + publish(entry, { + identity: JSON.stringify([target.endpoint, lease.id, lease.processId, lease.processStartedAt]), + application, + readiness: 'ready', + }); + if ([...entries.values()].some((candidate) => candidate !== entry && candidate.lease?.id === lease.id)) { + entry.uncertain = true; + throw new Error('The native host reused an application lease identifier; ownership is ambiguous.'); + } + entry.uncertain = false; + return entry.lease; + } catch (error) { + const data = errorData(error); + const application = Object.freeze({ + ...entry.record.application!, + ...(Number.isSafeInteger(data?.processId) && Number(data?.processId) > 0 ? { processId: Number(data?.processId) } : {}), + ...(typeof data?.processStartedAt === 'string' ? { processStartedAt: data.processStartedAt } : {}), + ...(data?.launchKind === 'launch-services' || data?.launchKind === 'direct-child' ? { launchKind: data.launchKind } : {}), + }); + if (!entry.lease) { + const effect = error && typeof error === 'object' && 'effect' in error ? error.effect : undefined; + const reportedProcess = data?.processId !== undefined || data?.processStartedAt !== undefined; + const mayHaveStarted = + reportedProcess || + (data?.applicationSettlement !== undefined && + data.applicationSettlement !== 'not-started' && + data.applicationSettlement !== 'exited') || + effect === 'applied' || + effect === 'partial' || + effect === 'unknown'; + const notStarted = data?.applicationSettlement === 'not-started' && !reportedProcess; + entry.uncertain = owns && data?.applicationSettlement !== 'exited' && !notStarted && mayHaveStarted; + } + try { + publish(entry, { + application, + readiness: 'failed', + disposal: owns ? (entry.uncertain ? 'failed' : 'settled') : 'not-owned', + }); + } catch (publication) { + throw new AggregateError([error, publication], 'Application acquisition and ownership publication failed.'); + } + throw error; + } + }; + const track = (work: Promise) => { + inFlight.add(work); + void work.finally(() => inFlight.delete(work)).catch(() => undefined); + return work; + }; + const borrowed = async (signal?: AbortSignal): Promise => { + if (closing || !retained) throw new Error('The supervisor-owned application is not available.'); + requireEntry(retained); + await host.windows(retained, signal); + return Object.freeze({ ...retained, ownership: 'attached' }); + }; + const overrides: Pick = { + launch: (target, signal) => { + if (options.lifetime === 'external') return Promise.reject(new Error('External applications may only be attached.')); + return options.lifetime === 'supervisor' ? borrowed(signal) : track(acquire(target, true, signal)); + }, + attach: (target, signal) => (options.lifetime === 'supervisor' ? borrowed(signal) : track(acquire(target, false, signal))), + closeApplication: async (lease, signal) => { + const entry = requireEntry(lease); + if (options.lifetime === 'supervisor' && entry.lease?.ownership === 'launched') return; + try { + await host.closeApplication(entry.lease!, signal); + publish(entry, { disposal: entry.record.ownership === 'owned' ? 'settled' : 'not-owned' }); + entries.delete(entry.record.id); + } catch (error) { + publish(entry, { disposal: entry.record.ownership === 'owned' ? 'failed' : 'not-owned' }); + throw error; + } + }, + dispose: (signal) => + (closing ??= (async () => { + await Promise.allSettled(inFlight); + const failures: unknown[] = []; + for (const entry of [...entries.values()].reverse()) { + if (!entry.lease || entry.record.disposal === 'settled') continue; + try { + requireEntry(entry.lease); + await host.closeApplication(entry.lease, signal); + entry.uncertain = false; + publish(entry, { disposal: entry.record.ownership === 'owned' ? 'settled' : 'not-owned' }); + } catch (error) { + failures.push(error); + try { + publish(entry, { disposal: entry.record.ownership === 'owned' ? 'failed' : 'not-owned' }); + } catch (publication) { + failures.push(publication); + } + } + } + try { + await host.dispose(signal); + } catch (error) { + failures.push(error); + } + if ([...entries.values()].some((entry) => entry.uncertain)) + failures.push(new Error('An application acquisition has no exact settled identity; its workload claim remains fenced.')); + if (failures.length) throw new AggregateError(failures, 'Application ownership cleanup did not settle.'); + })()), + }; + const wrapped = new Proxy(host, { + get(target, key) { + if (Object.hasOwn(overrides, key)) return overrides[key as keyof typeof overrides]; + const value = Reflect.get(target, key, target) as unknown; + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + if (options.lifetime === 'supervisor') { + try { + await host.probe(); + retained = await track(acquire({ ...options.target, host }, true)); + captureDesktopApplicationIdentity(retained, 'macos'); + } catch (error) { + try { + await wrapped.dispose(); + } catch (cleanup) { + throw new AggregateError([error, cleanup], 'Supervisor application acquisition and cleanup failed.'); + } + throw error; + } + } + return wrapped; +} + +function validateIdentity(value: unknown): asserts value is DesktopApplicationIdentity { + const identity = value as DesktopApplicationIdentity | undefined; + if ( + !identity || + identity.schemaVersion !== 1 || + identity.endpoint !== 'macos' || + !Number.isSafeInteger(identity.processId) || + identity.processId < 1 || + identity.processId > 0x7fffffff || + typeof identity.processStartedAt !== 'string' || + !/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{6}Z$/.test(identity.processStartedAt) + ) { + throw new TypeError('A macOS application identity requires an exact native PID and microsecond start timestamp.'); + } +} + +function errorData(error: unknown): Record | undefined { + if (!error || typeof error !== 'object' || !('data' in error) || !error.data || typeof error.data !== 'object') return undefined; + return error.data as Record; +} diff --git a/packages/agentic/desktop-test/src/config.ts b/packages/agentic/desktop-test/src/config.ts index f04c8a0a93..dd585787a1 100644 --- a/packages/agentic/desktop-test/src/config.ts +++ b/packages/agentic/desktop-test/src/config.ts @@ -24,7 +24,7 @@ export type DesktopStage = 'prep' | 'bundle' | 'build' | 'run'; export type DesktopHostConfig = { kind: 'installed' | 'react-native-test-app' | 'rex'; application?: NativeDesktopApplicationDescriptor; - ownership?: 'session' | 'external'; + ownership?: 'session' | 'supervisor' | 'external'; renderer?: DesktopRenderer; appConfigPath?: string; component?: string; @@ -145,7 +145,9 @@ export function validateDesktopConfig(value: unknown): asserts value is DesktopC const host = object(target.host, `${id}.host`); keys(host, ['kind', 'application', 'ownership', 'renderer', 'appConfigPath', 'component', 'windowTitle', 'bundleRoot'], `${id}.host`); oneOf(host.kind, ['installed', 'react-native-test-app', 'rex'], `${id}.host.kind`); - optionalEnum(host.ownership, ['session', 'external'], `${id}.host.ownership`); + optionalEnum(host.ownership, ['session', 'supervisor', 'external'], `${id}.host.ownership`); + if (host.ownership === 'supervisor' && (target.endpoint ?? id) !== 'macos') + throw new TypeError('Supervisor-owned application launch is currently supported only on macOS.'); optionalEnum(host.renderer, ['fabric', 'paper'], `${id}.host.renderer`); for (const field of ['appConfigPath', 'component', 'windowTitle', 'bundleRoot']) { if (host[field] !== undefined) text(host[field], `${id}.host.${field}`); @@ -154,7 +156,11 @@ export function validateDesktopConfig(value: unknown): asserts value is DesktopC if (host.kind === 'react-native-test-app' && (target.endpoint ?? id) === 'win32') { throw new TypeError('Win32 uses REX, not react-native-test-app.'); } - if (host.application !== undefined) validateApplication(host.application, `${id}.host.application`); + if (host.application !== undefined) { + validateApplication(host.application, `${id}.host.application`); + if (object(host.application, 'application').processIdentity !== undefined && (target.endpoint ?? id) !== 'macos') + throw new TypeError('Exact application processIdentity attach is currently supported only on macOS.'); + } if (host.kind === 'installed' && host.application === undefined) throw new TypeError(`${id}: installed hosts require application identity.`); if (target.readiness !== undefined) validateReadiness(target.readiness); @@ -451,17 +457,38 @@ function validateApplication(value: unknown, context: string): void { const application = object(value, context); keys( application, - ['arguments', 'aumid', 'bundleIdentifier', 'executablePath', 'leaseNonce', 'leasePath', 'windowTitle', 'workingDirectory'], + [ + 'arguments', + 'aumid', + 'bundleIdentifier', + 'executablePath', + 'leaseNonce', + 'leasePath', + 'processIdentity', + 'windowTitle', + 'workingDirectory', + ], context, ); for (const [field, setting] of Object.entries(application)) { if (field === 'arguments') { if (!Array.isArray(setting) || !setting.every((item) => typeof item === 'string')) throw new TypeError(`${context}.arguments must be strings.`); + } else if (field === 'processIdentity') { + const identity = object(setting, `${context}.processIdentity`); + keys(identity, ['processId', 'processStartedAt'], `${context}.processIdentity`); + positive(identity.processId, `${context}.processIdentity.processId`); + if (!Number.isSafeInteger(identity.processId) || Number(identity.processId) > 0x7fffffff) + throw new TypeError(`${context}.processIdentity.processId must be a positive 31-bit integer.`); + text(identity.processStartedAt, `${context}.processIdentity.processStartedAt`); + if (!/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{6}Z$/.test(String(identity.processStartedAt))) + throw new TypeError(`${context}.processIdentity.processStartedAt requires a native microsecond timestamp.`); } else text(setting, `${context}.${field}`); } if (Boolean(application.leasePath) !== Boolean(application.leaseNonce)) throw new TypeError(`${context}: leasePath and leaseNonce must be paired.`); + if (application.processIdentity !== undefined && application.leasePath) + throw new TypeError(`${context}: processIdentity cannot be combined with a legacy lease.`); } function validateReadiness(value: unknown): void { diff --git a/packages/agentic/desktop-test/src/contracts.contract.cjs b/packages/agentic/desktop-test/src/contracts.contract.cjs index 3f51848b02..1584d60d0a 100644 --- a/packages/agentic/desktop-test/src/contracts.contract.cjs +++ b/packages/agentic/desktop-test/src/contracts.contract.cjs @@ -1,12 +1,19 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const os = require('node:os'); const { pathToFileURL } = require('node:url'); const { Writable } = require('node:stream'); const { spawnSync } = require('node:child_process'); const { inspect } = require('node:util'); +const fixtureProcessIdentity = { + platform: process.platform === 'win32' ? 'windows' : 'macos', + pid: process.pid, + startedAt: process.platform === 'win32' ? '2026-11-12T00:00:00.1234567Z' : '2026-11-12T00:00:00.123456Z', + executablePath: process.execPath, + interactiveSession: process.platform === 'win32' ? 'windows-session:1' : 'macos-session:1', +}; + const mode = process.argv[2]; const packageRoot = path.resolve(__dirname, '..'); const lib = (filename) => pathToFileURL(path.join(packageRoot, 'lib', filename)).href; @@ -51,8 +58,8 @@ async function main() { const { NodeDesktopCommandRunner } = await import(lib('commands.js')); const { DesktopServiceScope, DesktopStopRequested } = await import(lib('services.js')); const { loadDesktopConfig, defineDesktopConfig, resolveDesktopSpecs, resolveDesktopTestScope } = await import(lib('config.js')); - // Config resolution canonicalizes paths; macOS temporary directories are symlinked (/var -> /private/var). - const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'desktop-harness-contract-'))); + const root = fs.realpathSync.native(fs.mkdtempSync(path.join(process.cwd(), '.desktop-harness-contract-'))); + process.env.FURN_DESKTOP_DRIVER_CACHE_ROOT = path.join(root, 'helper-cache'); const out = capture(); const err = capture(); const reporter = (options = {}) => @@ -1315,6 +1322,7 @@ async function main() { const failedDirectory = path.join(root, 'failed-lock'); const failedClaim = createDesktopWorkloadClaim({ + processIdentity: fixtureProcessIdentity, runId: 'failed-run', targetId: 'registered-target', targetInstanceId: 'physical-target', @@ -1385,6 +1393,7 @@ async function main() { const marker = path.join(root, 'dependent-started'); const serviceRun = reporter({ kind: 'lifecycle' }); const serviceSupervisor = new DesktopSupervisor({ + processIdentity: fixtureProcessIdentity, target: { endpoint: 'win32', host: { kind: 'installed', application: { windowTitle: 'fixture' } }, @@ -1418,6 +1427,7 @@ async function main() { const reportLockDirectory = path.join(root, 'report-lock'); const reportSupervisor = new DesktopSupervisor({ + processIdentity: fixtureProcessIdentity, target: { endpoint: 'win32', host: { kind: 'installed', application: { windowTitle: 'fixture' } }, @@ -1445,6 +1455,7 @@ async function main() { directory: workloadDirectory, }); const workloadSupervisor = new DesktopSupervisor({ + processIdentity: fixtureProcessIdentity, target: { endpoint: 'win32', host: { kind: 'installed', application: { windowTitle: 'fixture' } }, @@ -1479,6 +1490,7 @@ async function main() { let removed = 0; const run = reporter({ kind: 'lifecycle' }); const supervisor = new DesktopSupervisor({ + processIdentity: fixtureProcessIdentity, target, recovery: memoryRecovery(), reporter: run, @@ -1526,6 +1538,7 @@ async function main() { assert(!fs.existsSync(path.join(root, 'locks', 'interactive.lock'))); const failing = new DesktopSupervisor({ + processIdentity: fixtureProcessIdentity, target, recovery: memoryRecovery(), reporter: reporter({ kind: 'lifecycle' }), @@ -1543,6 +1556,7 @@ async function main() { const failedCleanupLocks = path.join(root, 'failed-cleanup-locks'); const failedCleanup = new DesktopSupervisor({ + processIdentity: fixtureProcessIdentity, target, recovery: memoryRecovery(), reporter: reporter({ kind: 'lifecycle' }), @@ -1561,7 +1575,7 @@ async function main() { await assert.rejects(failedCleanup.close(), /cleanup failed/); assert(fs.existsSync(path.join(failedCleanupLocks, 'interactive.lock')), 'uncertain cleanup must retain the workload claim'); const { claimDesktopWorkload } = await import(lib('supervisor.js')); - assert.throws(() => claimDesktopWorkload('replacement', failedCleanupLocks), /Another desktop workload/); + await assert.rejects(claimDesktopWorkload('replacement', failedCleanupLocks, fixtureProcessIdentity), /Another desktop workload/); // The Storybook channel child's composition: one service, protected and published from the process that listens. const { startDesktopService } = await import(lib('driverService.js')); @@ -1709,6 +1723,7 @@ async function main() { return { server, host, close: () => server.close() }; } const supervisor = new DesktopSupervisor({ + processIdentity: fixtureProcessIdentity, target, recovery, reporter: reporter({ kind: 'lifecycle' }), @@ -1851,7 +1866,7 @@ async function main() { command: process.execPath, args: [path.join(packageRoot, 'config', 'cli.cjs'), '--config', configPath, '--target', 'win32', ...args], cwd: root, - env: { FIXTURE_MARKER: marker, FIXTURE_SUPPORTS_AGENT: supportsAgent ? '1' : '0' }, + env: { HOME: root, LOCALAPPDATA: root, FIXTURE_MARKER: marker, FIXTURE_SUPPORTS_AGENT: supportsAgent ? '1' : '0' }, }); } catch (error) { failure = error; @@ -2026,7 +2041,11 @@ async function main() { const child = spawnSync( process.execPath, [path.join(packageRoot, 'config', 'cli.cjs'), '--config', configPath, '--target', 'win32', '--reporter', 'json', ...args], - { cwd: root, encoding: 'utf8', env: { ...inherited, FIXTURE_MARKER: marker, FIXTURE_ARTIFACTS: artifacts, ...environment } }, + { + cwd: root, + encoding: 'utf8', + env: { ...inherited, HOME: root, LOCALAPPDATA: root, FIXTURE_MARKER: marker, FIXTURE_ARTIFACTS: artifacts, ...environment }, + }, ); const runs = fs.existsSync(artifacts) ? fs.readdirSync(artifacts) : []; return { @@ -2201,6 +2220,7 @@ async function main() { return launch(...args); }; const supervisor = new DesktopSupervisor({ + processIdentity: fixtureProcessIdentity, target, recovery: memoryRecovery(), reporter: run, @@ -2244,6 +2264,7 @@ async function main() { try { await supervisor.start(); const duplicate = new DesktopSupervisor({ + processIdentity: fixtureProcessIdentity, target, recovery: memoryRecovery(), reporter: run, diff --git a/packages/agentic/desktop-test/src/driverService.ts b/packages/agentic/desktop-test/src/driverService.ts index d2f2a2535c..9f57bef6bf 100644 --- a/packages/agentic/desktop-test/src/driverService.ts +++ b/packages/agentic/desktop-test/src/driverService.ts @@ -31,6 +31,8 @@ export type DesktopServiceOptions = { addSecret?: (secret: string) => void; signal?: AbortSignal; guard?: (work: Promise) => Promise; + /** Trusted neutral application-lifetime wrapper, installed before integration target context is created. */ + prepareHost?: (host: DesktopHost) => Promise; /** Optional application-specific target context; native authority remains in the driver. */ createTarget?: ( host: DesktopHost, @@ -71,6 +73,7 @@ export async function startDesktopService( let runnerPublication: DesktopAgentPublication | undefined; try { const recovery = await resolveRecovery(options, artifact); + desktopHost = (await options.prepareHost?.(desktopHost)) ?? desktopHost; // Only the declared story context is taken: an integration cannot replace the target's identity or launch modes. const added = await options.createTarget?.(desktopHost, recovery); const target = { diff --git a/packages/agentic/desktop-test/src/ownership.test.ts b/packages/agentic/desktop-test/src/ownership.test.ts new file mode 100644 index 0000000000..d9addb25ea --- /dev/null +++ b/packages/agentic/desktop-test/src/ownership.test.ts @@ -0,0 +1,91 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { createDesktopWorkloadClaim, inspectDesktopWorkloadClaim } from './ownership'; +import type { DesktopWorkloadProcessIdentity } from './ownership'; + +const identity: DesktopWorkloadProcessIdentity = { + platform: 'macos', + pid: process.pid, + startedAt: '2026-11-12T00:00:00.123456Z', + executablePath: process.execPath, + interactiveSession: 'macos-session:1', +}; + +describe('desktop workload exact owner identity', () => { + let directory: string; + beforeEach(() => { + directory = fs.mkdtempSync(path.join(process.cwd(), '.ownership-test-')); + }); + afterEach(() => fs.rmSync(directory, { force: true, recursive: true })); + + const claimOptions = () => ({ runId: 'run', targetId: 'target', targetInstanceId: 'instance', directory }); + + test('compares the unchanged native start identity, including microseconds', async () => { + const claim = createDesktopWorkloadClaim({ ...claimOptions(), processIdentity: identity }); + const filename = path.join(directory, 'interactive.lock'); + expect(claim.record.schemaVersion).toBe(3); + expect(claim.record.owner.processStartedAt).toBe(identity.startedAt); + await expect( + inspectDesktopWorkloadClaim(filename, async () => ({ state: 'alive', processStartedAt: identity.startedAt })), + ).resolves.toMatchObject({ state: 'live' }); + await expect( + inspectDesktopWorkloadClaim(filename, async () => ({ state: 'alive', processStartedAt: '2026-11-12T00:00:00.123457Z' })), + ).resolves.toMatchObject({ state: 'pid-reused' }); + await expect(inspectDesktopWorkloadClaim(filename, async () => ({ state: 'unknown' }))).resolves.toMatchObject({ state: 'unknown' }); + expect(fs.existsSync(filename)).toBe(true); + claim.release(); + }); + + test.each(['legacy', 'unverified'])('%s claims never compare a clock estimate to an OS timestamp', async (kind) => { + const claim = createDesktopWorkloadClaim(claimOptions()); + const filename = path.join(directory, 'interactive.lock'); + expect(claim.record.owner.processStartedAt).toBeUndefined(); + if (kind === 'legacy') { + fs.writeFileSync( + filename, + JSON.stringify({ + ...claim.record, + schemaVersion: 2, + owner: { ...claim.record.owner, processStartedAt: '2026-11-12T00:00:00.123Z' }, + }), + ); + } + const probe = jest.fn(async () => ({ state: 'exited' as const })); + await expect(inspectDesktopWorkloadClaim(filename, probe)).resolves.toMatchObject({ state: 'unknown' }); + expect(probe).not.toHaveBeenCalled(); + expect(fs.existsSync(filename)).toBe(true); + }); + + test('refuses another process identity and corrupted native identity fields', async () => { + expect(() => createDesktopWorkloadClaim({ ...claimOptions(), processIdentity: { ...identity, pid: process.pid + 1 } })).toThrow( + /native operating-system identity/, + ); + const claim = createDesktopWorkloadClaim({ ...claimOptions(), processIdentity: identity }); + const filename = path.join(directory, 'interactive.lock'); + fs.writeFileSync(filename, JSON.stringify({ ...claim.record, owner: { ...claim.record.owner, processStartedAt: 'different' } })); + await expect(inspectDesktopWorkloadClaim(filename, async () => ({ state: 'exited' }))).rejects.toThrow(/invalid/); + }); + + test('contends for one per-user claim even when TMPDIR changes', () => { + const saved = { HOME: process.env.HOME, LOCALAPPDATA: process.env.LOCALAPPDATA, TMPDIR: process.env.TMPDIR }; + process.env.HOME = directory; + process.env.LOCALAPPDATA = directory; + process.env.TMPDIR = path.join(directory, 'first'); + const options = { runId: 'one', targetId: 'target', targetInstanceId: 'instance' }; + try { + const claim = createDesktopWorkloadClaim(options); + try { + process.env.TMPDIR = path.join(directory, 'second'); + expect(() => createDesktopWorkloadClaim({ ...options, runId: 'two' })).toThrow(/Another desktop workload/); + } finally { + claim.release(); + } + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + }); +}); diff --git a/packages/agentic/desktop-test/src/ownership.ts b/packages/agentic/desktop-test/src/ownership.ts index a22e45e7cf..b7111167db 100644 --- a/packages/agentic/desktop-test/src/ownership.ts +++ b/packages/agentic/desktop-test/src/ownership.ts @@ -3,12 +3,22 @@ import os from 'node:os'; import path from 'node:path'; import { randomUUID } from 'node:crypto'; +import { describeMacOSService, describeWindowsService } from '@fluentui-react-native/desktop-driver/authority'; +import type { DesktopServiceIdentity } from '@fluentui-react-native/desktop-driver/authority'; +import { resolveNativeDesktopDriver } from '@fluentui-react-native/desktop-driver/native'; +import { NativeDriverError } from '@fluentui-react-native/desktop-driver'; +import type { NativeDriverResolveOptions } from '@fluentui-react-native/desktop-driver'; + import type { DesktopResourceRecord } from './resources.js'; +export type DesktopWorkloadProcessIdentity = Readonly>; + export type DesktopWorkloadOwnerIdentity = Readonly<{ id: string; processId: number; - processStartedAt: string; + /** Absent when a caller cannot provide the verified operating-system identity. Never derived from wall-clock uptime. */ + processStartedAt?: string; + processIdentity?: DesktopWorkloadProcessIdentity; runId: string; targetId: string; targetInstanceId: string; @@ -21,7 +31,7 @@ export type DesktopUnresolvedEffect = Readonly<{ }>; export type DesktopWorkloadOwnershipRecord = Readonly<{ - schemaVersion: 2; + schemaVersion: 2 | 3; state: 'active' | 'fenced'; owner: DesktopWorkloadOwnerIdentity; resources: readonly DesktopResourceRecord[]; @@ -49,8 +59,30 @@ type DesktopWorkloadClaimOptions = { targetId: string; targetInstanceId: string; directory?: string; + /** Obtained from the driver's native service-identity describe operation, not a JavaScript clock estimate. */ + processIdentity?: DesktopWorkloadProcessIdentity; }; +export async function describeDesktopWorkloadOwner( + nativeDriver?: Omit, +): Promise { + if (process.platform !== 'darwin' && process.platform !== 'win32') return undefined; + try { + const artifact = await resolveNativeDesktopDriver({ + ...nativeDriver, + platform: process.platform === 'darwin' ? 'macos' : 'windows', + buildPolicy: 'never', + }); + return process.platform === 'darwin' + ? describeMacOSService({ artifact, pid: process.pid }) + : describeWindowsService({ artifact, pid: process.pid }); + } catch (error) { + // Report-only/custom integrations can run without a helper. Their claim cannot authorize automatic recovery. + if (error instanceof NativeDriverError && error.code === 'no-verified-prebuilt') return undefined; + throw error; + } +} + /** * The per-user claim directory. It is independent of TMPDIR, so workloads started with different temporary directories, * and from different checkouts, contend for the same claim. @@ -67,19 +99,26 @@ function defaultDesktopWorkloadDirectory(platform: NodeJS.Platform = process.pla } export function createDesktopWorkloadClaim(options: DesktopWorkloadClaimOptions): DesktopWorkloadClaim { + if (options.processIdentity && !validProcessIdentity(options.processIdentity, process.pid)) + throw new TypeError("Desktop workload ownership requires this process's native operating-system identity."); const root = options.directory ?? defaultDesktopWorkloadDirectory(); fs.mkdirSync(root, { recursive: true, mode: 0o700 }); const filename = path.join(root, 'interactive.lock'); const owner: DesktopWorkloadOwnerIdentity = Object.freeze({ id: randomUUID(), processId: process.pid, - processStartedAt: new Date(Date.now() - process.uptime() * 1000).toISOString(), + ...(options.processIdentity + ? { + processStartedAt: options.processIdentity.startedAt, + processIdentity: Object.freeze({ ...options.processIdentity }), + } + : {}), runId: options.runId, targetId: options.targetId, targetInstanceId: options.targetInstanceId, }); let current: DesktopWorkloadOwnershipRecord = Object.freeze({ - schemaVersion: 2, + schemaVersion: 3, state: 'active', owner, resources: Object.freeze([]), @@ -111,7 +150,9 @@ export function createDesktopWorkloadClaim(options: DesktopWorkloadClaimOptions) }, updateResource(resource) { const resources = current.resources.filter((entry) => entry.id !== resource.id); - resources.push(Object.freeze({ ...resource })); + resources.push( + Object.freeze({ ...resource, ...(resource.application ? { application: Object.freeze({ ...resource.application }) } : {}) }), + ); const unresolvedEffects = current.unresolvedEffects.filter((effect) => effect.resourceId !== resource.id); if (resource.ownership === 'owned' && resource.disposal === 'failed') { unresolvedEffects.push({ @@ -167,6 +208,8 @@ export async function inspectDesktopWorkloadClaim( probe: (owner: DesktopWorkloadOwnerIdentity) => Promise, ): Promise { const record = parseOwnershipRecord(fs.readFileSync(filename, 'utf8')); + // v2 used a wall-clock estimate. Comparing it with a kernel timestamp would misidentify a live owner as PID reuse. + if (record.schemaVersion !== 3 || !record.owner.processIdentity) return { state: 'unknown', record }; const result = await probe(record.owner); if (result.state === 'unknown') return { state: 'unknown', record }; if (result.state === 'exited') return { state: 'exited', record }; @@ -198,12 +241,16 @@ function replaceFileAtomically(filename: string, contents: string): void { function parseOwnershipRecord(contents: string): DesktopWorkloadOwnershipRecord { const record = JSON.parse(contents) as DesktopWorkloadOwnershipRecord; if ( - record.schemaVersion !== 2 || + (record.schemaVersion !== 2 && record.schemaVersion !== 3) || (record.state !== 'active' && record.state !== 'fenced') || !record.owner || typeof record.owner.id !== 'string' || !Number.isSafeInteger(record.owner.processId) || - typeof record.owner.processStartedAt !== 'string' || + record.owner.processId < 1 || + (record.owner.processStartedAt !== undefined && typeof record.owner.processStartedAt !== 'string') || + (record.owner.processIdentity !== undefined && + (!validProcessIdentity(record.owner.processIdentity, record.owner.processId) || + record.owner.processStartedAt !== record.owner.processIdentity.startedAt)) || typeof record.owner.runId !== 'string' || typeof record.owner.targetId !== 'string' || typeof record.owner.targetInstanceId !== 'string' || @@ -214,3 +261,20 @@ function parseOwnershipRecord(contents: string): DesktopWorkloadOwnershipRecord } return record; } + +function validProcessIdentity(value: DesktopWorkloadProcessIdentity, pid: number): boolean { + return ( + value !== null && + typeof value === 'object' && + value.pid === pid && + typeof value.executablePath === 'string' && + path.isAbsolute(value.executablePath) && + typeof value.interactiveSession === 'string' && + (value.platform === 'macos' + ? /^macos-session:[0-9]+$/.test(value.interactiveSession) && + /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{6}Z$/.test(value.startedAt) + : value.platform === 'windows' && + /^windows-session:[0-9]+$/.test(value.interactiveSession) && + /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{7}Z$/.test(value.startedAt)) + ); +} diff --git a/packages/agentic/desktop-test/src/resources.ts b/packages/agentic/desktop-test/src/resources.ts index c2281bb920..062a81fa82 100644 --- a/packages/agentic/desktop-test/src/resources.ts +++ b/packages/agentic/desktop-test/src/resources.ts @@ -1,5 +1,15 @@ import type { DesktopServiceReadyRecord } from './processControl.js'; +export type DesktopApplicationResource = Readonly<{ + endpoint: 'macos' | 'windows' | 'win32'; + lifetime: 'session' | 'supervisor' | 'external'; + leaseId?: string; + processId?: number; + processStartedAt?: string; + containment?: 'external' | 'job-object' | 'uncontained'; + launchKind?: 'launch-services' | 'direct-child'; +}>; + export type DesktopResourceServiceReadyRecord = Readonly; export type DesktopResourceOwnership = 'owned' | 'borrowed'; @@ -16,6 +26,7 @@ export type DesktopResourceRecord = Readonly<{ readiness: DesktopResourceReadiness; disposal: DesktopResourceDisposal; serviceReady?: DesktopResourceServiceReadyRecord; + application?: DesktopApplicationResource; }>; export type DesktopResourcePlan = { diff --git a/packages/agentic/desktop-test/src/supervisor.test.ts b/packages/agentic/desktop-test/src/supervisor.test.ts index be170d3ab5..b447ebb2d5 100644 --- a/packages/agentic/desktop-test/src/supervisor.test.ts +++ b/packages/agentic/desktop-test/src/supervisor.test.ts @@ -51,6 +51,13 @@ describe('desktop supervisor input lane', () => { reportSession: { reporter, delegated: true, environment: {}, close: async () => 0 }, // Never contend for the real per-user workload claim. lockDirectory: path.join(artifactsRoot, 'locks'), + processIdentity: { + platform: 'macos', + pid: process.pid, + startedAt: '2026-11-12T00:00:00.123456Z', + executablePath: process.execPath, + interactiveSession: 'macos-session:1', + }, ...(input ? { input } : {}), }); try { diff --git a/packages/agentic/desktop-test/src/supervisor.ts b/packages/agentic/desktop-test/src/supervisor.ts index 779f035973..f5c54dcd5e 100644 --- a/packages/agentic/desktop-test/src/supervisor.ts +++ b/packages/agentic/desktop-test/src/supervisor.ts @@ -11,9 +11,10 @@ import type { DesktopIntegration } from './integration.js'; import { NodeDesktopCommandRunner, deadline } from './commands.js'; import type { PreparedDesktopCommand, RunningDesktopCommand } from './commands.js'; import { DesktopServiceScope } from './services.js'; -import { createDesktopWorkloadClaim } from './ownership.js'; -import type { DesktopWorkloadClaim } from './ownership.js'; +import { createDesktopWorkloadClaim, describeDesktopWorkloadOwner } from './ownership.js'; +import type { DesktopWorkloadClaim, DesktopWorkloadProcessIdentity } from './ownership.js'; import { resolveDesktopApplication } from './adapters.js'; +import { createDesktopApplicationHost } from './applicationOwnership.js'; import { DesktopRunReporter } from './reporting/collector.js'; import type { DesktopReportWriter } from './reporting/collector.js'; import type { DesktopReportSession } from './reporting/session.js'; @@ -28,6 +29,8 @@ export type DesktopSupervisorOptions = { workload?: { kind: 'delegated'; claim: DesktopWorkloadClaim }; hostFactory?: (target: ResolvedDesktopTarget) => Promise; lockDirectory?: string; + /** Trusted native identity override, useful to custom hosts that do not resolve a native driver artifact. */ + processIdentity?: DesktopWorkloadProcessIdentity; /** * Protects the service with a grant authority and publishes one agent's connection descriptor once the target is * ready. Every route then requires a grant, so only clients given one, such as the Desktop Driver MCP adapter, connect. @@ -89,6 +92,9 @@ export class DesktopSupervisor { targetId: this.target.id, targetInstanceId: this.target.instanceId, directory: this.options.lockDirectory, + processIdentity: + this.options.processIdentity ?? + (this.options.hostFactory ? undefined : await describeDesktopWorkloadOwner(this.target.nativeDriver)), }); this.ownsWorkloadClaim = true; } @@ -148,6 +154,21 @@ export class DesktopSupervisor { signal: this.services.controller.signal, guard: (work) => this.services.guard(work), addSecret: (secret) => this.reporter.addSecret(secret), + prepareHost: (host) => + createDesktopApplicationHost(host, { + target: options.target, + lifetime: this.target.host.ownership ?? 'session', + resourcePrefix: `${this.reporter.runId}:${this.target.instanceId}`, + onResource: (resource) => { + try { + this.workloadClaim?.updateResource(resource); + } catch (error) { + this.ownershipUncertain = true; + this.services.fail(error instanceof Error ? error : new Error(String(error))); + throw error; + } + }, + }), createTarget: (host) => this.options.integration?.createTarget?.({ host, target: this.target, reporter: this.reporter }) ?? Promise.resolve(undefined), }; @@ -293,12 +314,17 @@ export class DesktopSupervisor { } } -export function claimDesktopWorkload(runId: string, directory?: string): () => void { +export async function claimDesktopWorkload( + runId: string, + directory?: string, + processIdentity?: DesktopWorkloadProcessIdentity, +): Promise<() => void> { const claim = createDesktopWorkloadClaim({ runId, targetId: 'unregistered', targetInstanceId: 'unregistered', directory, + processIdentity: processIdentity ?? (await describeDesktopWorkloadOwner()), }); return () => claim.release(); } diff --git a/packages/agentic/storybook-desktop/config/server.cjs b/packages/agentic/storybook-desktop/config/server.cjs index c8cfbb5403..3fe796e433 100644 --- a/packages/agentic/storybook-desktop/config/server.cjs +++ b/packages/agentic/storybook-desktop/config/server.cjs @@ -160,7 +160,7 @@ async function startDesktopStorybookServer({ server.on('error', fatal); keepAlive = setInterval(() => undefined, 60_000); if (driverManifest) { - const { StorybookChannelOrchestrator } = await import('../lib/driver/index.js'); + const { createApplicationIdentityResolver, StorybookChannelOrchestrator } = await import('../lib/driver/index.js'); // An accessible driver cannot perform physical input, so every test that attaches to it runs the accessible lane. const maskedFeatures = driverManifest.wdio?.input === 'accessible' @@ -212,7 +212,7 @@ async function startDesktopStorybookServer({ } : {}), target, - createTarget: async (_targetHost, recovery) => ({ + createTarget: async (targetHost, recovery) => ({ storyOrchestrator: createChannelOrchestrator({ channelServer: server, driverManifest, @@ -220,22 +220,16 @@ async function startDesktopStorybookServer({ StorybookChannelOrchestrator, recovery: { ...recovery, - resolveApplication: async () => { - // A separate helper cannot steal window/element records from an active session's lease. - const identityHost = new NativeDesktopHost({ - application: agent?.application ?? driverManifest.application, - artifact: driverManifest.nativeDriver, - endpoint: driverManifest.endpoint, - }); - try { - const lease = await identityHost.attach({ ...target, host: identityHost }, AbortSignal.timeout(10_000)); - if (lease.processId === undefined || lease.processStartedAt === undefined) - throw new Error('The Storybook application has no verified process incarnation.'); - return { processId: lease.processId, processStartedAt: lease.processStartedAt }; - } finally { - await identityHost.dispose(); - } - }, + resolveApplication: createApplicationIdentityResolver({ + host: targetHost, + target: { ...target, host: targetHost }, + createIdentityHost: () => + new NativeDesktopHost({ + application: agent?.application ?? driverManifest.application, + artifact: driverManifest.nativeDriver, + endpoint: driverManifest.endpoint, + }), + }), }, }), }), diff --git a/packages/agentic/storybook-desktop/references/contributing.md b/packages/agentic/storybook-desktop/references/contributing.md index 0543677c64..a702579f16 100644 --- a/packages/agentic/storybook-desktop/references/contributing.md +++ b/packages/agentic/storybook-desktop/references/contributing.md @@ -102,12 +102,17 @@ readiness contracts change. Authenticated bridge epochs are recorded by `BridgeEpochJournal` in the service factory's private endpoint/target namespace, separately from settlement and quarantine. The record binds renderer and epoch to a native-verified PID -and process start time. Identity resolution uses a temporary attached helper, -disposed without closing the app or altering an active session's window records. +and process start time. On macOS, `createApplicationIdentityResolver` uses the +session helper's read-only `inspectApplication` operation: descriptor/lease +verification and exact-window discovery never acquire another controlling +lease or alter window/element records. The existing per-incarnation application +lock still excludes foreign controlling services. Windows/Win32 retain their +temporary attached identity helper, disposed without closing the app or +altering an active session's window records. Readiness remains disabled until the record is published; a missing startup lease can be retried on the next story command, never assumed verified. After a service restart, only a matching record, a `running` exact-incarnation -probe, and a matching current native attachment justify `relaunch-required`. +probe, and a matching current native identity justify `relaunch-required`. Initial absence, an unknown/failed probe, or a replaced process stays `bridge-unavailable`, with no navigation dispatched. Proven exited/replaced records are removed; reauthentication replaces the old epoch. Neither record diff --git a/packages/agentic/storybook-desktop/src/driver/applicationIdentity.test.ts b/packages/agentic/storybook-desktop/src/driver/applicationIdentity.test.ts new file mode 100644 index 0000000000..ae07d21ad6 --- /dev/null +++ b/packages/agentic/storybook-desktop/src/driver/applicationIdentity.test.ts @@ -0,0 +1,148 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { createDesktopDriverClient, createDesktopDriverServer, NativeDriverError } from '@fluentui-react-native/desktop-driver'; +import type { ApplicationLease, DesktopTarget } from '@fluentui-react-native/desktop-driver'; +import { FakeDesktopHost } from '@fluentui-react-native/desktop-driver/testing'; +import { createDesktopWorkloadClaim } from '@fluentui-react-native/desktop-test'; +import { createDesktopApplicationHost } from '@fluentui-react-native/desktop-test/application-ownership'; + +import { createApplicationIdentityResolver } from './applicationIdentity'; +import { BridgeEpochJournal } from './BridgeEpochJournal'; + +const identity = { processId: 123, processStartedAt: '2026-11-12T00:00:00.123456Z' }; + +test.each(['fabric', 'paper'] as const)( + 'launch → claim → embedded-driver attach → %s epoch inspection retains exact application exclusion', + async (renderer) => { + const directory = fs.mkdtempSync(path.join(process.cwd(), '.application-identity-test-')); + const registration = { + endpoint: 'macos', + id: 'storybook', + launchModes: ['attach'], + platformName: 'macos', + renderer, + } satisfies Omit; + const claim = createDesktopWorkloadClaim({ + directory, + runId: 'bridge-epoch', + targetId: registration.id, + targetInstanceId: registration.id, + }); + const native = new FakeDesktopHost({ endpoint: 'macos' }); + let held = false; + jest.spyOn(native, 'launch').mockImplementation(async () => { + held = true; + return { ...identity, id: 'owned-application', ownership: 'launched', containment: 'uncontained' }; + }); + jest.spyOn(native, 'closeApplication').mockImplementation(async () => { + held = false; + }); + const inspectApplication = jest.fn(async (_target: DesktopTarget, signal?: AbortSignal) => { + signal?.throwIfAborted(); + expect(held).toBe(true); + return identity; + }); + const createIdentityHost = jest.fn(() => { + const foreign = new FakeDesktopHost({ endpoint: 'macos' }); + jest.spyOn(foreign, 'attach').mockImplementation(async () => { + if (held) + throw new NativeDriverError('attach-failed', 'Another service controls this incarnation.', { reason: 'application-lock-busy' }); + return { ...identity, id: 'foreign-application', ownership: 'attached' }; + }); + return foreign; + }); + const host = await createDesktopApplicationHost(Object.assign(native, { inspectApplication }), { + target: registration, + lifetime: 'supervisor', + resourcePrefix: registration.id, + onResource: (resource) => claim.updateResource(resource), + }); + let server: Awaited> | undefined; + try { + expect(claim.record.resources).toEqual([ + expect.objectContaining({ ownership: 'owned', application: expect.objectContaining(identity) }), + ]); + const target = { ...registration, host }; + server = await createDesktopDriverServer({ port: 0, targets: [target] }); + const client = createDesktopDriverClient({ url: server.url }); + const session = await client.newSession({ + alwaysMatch: { platformName: 'macos', 'furn:target': registration.id, 'furn:launchMode': 'attach' }, + }); + const before = await session.getWindowHandles(); + const records = new Map(); + const journal = new BridgeEpochJournal( + { + resolveApplication: createApplicationIdentityResolver({ host: { inspectApplication }, target, createIdentityHost }), + probeApplication: async () => 'running', + storage: { + read: async (name) => records.get(name), + publish: async (name, value) => { + records.set(name, value); + }, + remove: async (name, value) => records.get(name) === value && records.delete(name), + }, + }, + { endpoint: registration.endpoint, renderer, targetId: registration.id }, + ); + await journal.record('authenticated-epoch'); + await expect(journal.requiresRelaunch()).resolves.toBe(true); + expect(inspectApplication).toHaveBeenCalledTimes(2); + expect(createIdentityHost).not.toHaveBeenCalled(); + expect(JSON.parse(records.get('bridge-epoch.json')!)).toMatchObject({ application: identity, epoch: 'authenticated-epoch' }); + await expect(session.getWindowHandles()).resolves.toEqual(before); + const foreign = createIdentityHost(); + await expect(foreign.attach({ ...registration, host: foreign })).rejects.toMatchObject({ + data: { reason: 'application-lock-busy' }, + }); + await foreign.dispose(); + await session.delete(); + expect(held).toBe(true); + } finally { + try { + await server?.close(); + await host.dispose(); + expect(held).toBe(false); + claim.release(); + expect(fs.existsSync(path.join(directory, 'interactive.lock'))).toBe(false); + } finally { + fs.rmSync(directory, { force: true, recursive: true }); + } + } + }, +); + +test.each(['windows', 'win32'] as const)('keeps %s identity attachments isolated and disposes them on failure', async (endpoint) => { + const host = new FakeDesktopHost({ endpoint }); + const inspectApplication = jest.fn(); + const attach = jest.spyOn(host, 'attach'); + const dispose = jest.spyOn(host, 'dispose'); + const resolve = createApplicationIdentityResolver({ + host: { inspectApplication }, + target: { endpoint, host, id: 'storybook', platformName: 'windows' }, + createIdentityHost: () => host, + }); + const lease: ApplicationLease = { ...identity, id: 'external-application', ownership: 'attached' }; + attach.mockResolvedValueOnce(lease).mockRejectedValueOnce(new Error('Identity unavailable')); + await expect(resolve()).resolves.toEqual(identity); + await expect(resolve()).rejects.toThrow('Identity unavailable'); + expect(inspectApplication).not.toHaveBeenCalled(); + expect(dispose).toHaveBeenCalledTimes(2); +}); + +test('a failed macOS observation neither falls back to controlling attachment nor disposes the session host', async () => { + const host = new FakeDesktopHost({ endpoint: 'macos' }); + const dispose = jest.spyOn(host, 'dispose'); + const createIdentityHost = jest.fn(() => host); + const inspectApplication = jest.fn(async () => { + throw new Error('Exact identity unavailable'); + }); + const resolve = createApplicationIdentityResolver({ + host: { inspectApplication }, + target: { endpoint: 'macos', host, id: 'storybook', platformName: 'macos' }, + createIdentityHost, + }); + await expect(resolve()).rejects.toThrow('Exact identity unavailable'); + expect(createIdentityHost).not.toHaveBeenCalled(); + expect(dispose).not.toHaveBeenCalled(); +}); diff --git a/packages/agentic/storybook-desktop/src/driver/applicationIdentity.ts b/packages/agentic/storybook-desktop/src/driver/applicationIdentity.ts new file mode 100644 index 0000000000..7c271560a4 --- /dev/null +++ b/packages/agentic/storybook-desktop/src/driver/applicationIdentity.ts @@ -0,0 +1,25 @@ +import type { DesktopHost, DesktopTarget, NativeDesktopHost } from '@fluentui-react-native/desktop-driver'; + +import type { BridgeEpochRecovery } from './BridgeEpochJournal.js'; + +export function createApplicationIdentityResolver(options: { + host: Pick; + target: DesktopTarget; + createIdentityHost(): DesktopHost; +}): BridgeEpochRecovery['resolveApplication'] { + return async () => { + const signal = AbortSignal.timeout(10_000); + if (options.target.endpoint === 'macos') return options.host.inspectApplication(options.target, signal); + + // Other providers retain their existing temporary attachment without changing the session's window records. + const identityHost = options.createIdentityHost(); + try { + const lease = await identityHost.attach({ ...options.target, host: identityHost }, signal); + if (lease.processId === undefined || lease.processStartedAt === undefined) + throw new Error('The Storybook application has no verified process incarnation.'); + return { processId: lease.processId, processStartedAt: lease.processStartedAt }; + } finally { + await identityHost.dispose(); + } + }; +} diff --git a/packages/agentic/storybook-desktop/src/driver/index.ts b/packages/agentic/storybook-desktop/src/driver/index.ts index 8cb348c05b..ac03003107 100644 --- a/packages/agentic/storybook-desktop/src/driver/index.ts +++ b/packages/agentic/storybook-desktop/src/driver/index.ts @@ -5,5 +5,6 @@ export type { DesktopStorybookDriverManifest, } from './driverManifest.js'; export { StorybookChannelOrchestrator } from './StorybookChannelOrchestrator.js'; +export { createApplicationIdentityResolver } from './applicationIdentity.js'; export type { StorybookChannelOrchestratorOptions, StorybookChannelServer } from './StorybookChannelOrchestrator.js'; export { createDesktopStoryManifest, writeDesktopStoryManifest } from './storyManifest.js';