From 19bc0cda668c294dbdff8e76dc3344a29ea609e8 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 12 Nov 2026 01:56:07 +0000 Subject: [PATCH 01/20] Add neutral host preparation seam to desktop service startup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/agentic/desktop-test/src/driverService.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/agentic/desktop-test/src/driverService.ts b/packages/agentic/desktop-test/src/driverService.ts index 34641f9cad..5406b58f70 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, @@ -70,6 +72,7 @@ export async function startDesktopService( let publication: 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 = { From 50c1f86fcdb8fbb44979998d63f42a136ef3c5f0 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 12 Nov 2026 02:03:45 +0000 Subject: [PATCH 02/20] Add isolated AppKit fixture for native application ownership contracts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../desktop-driver/native/macos/README.md | 18 +++++++ .../macos/fixtures/ApplicationFixture.swift | 51 +++++++++++++++++++ .../native/macos/fixtures/build.cjs | 45 ++++++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 packages/agentic/desktop-driver/native/macos/fixtures/ApplicationFixture.swift create mode 100644 packages/agentic/desktop-driver/native/macos/fixtures/build.cjs diff --git a/packages/agentic/desktop-driver/native/macos/README.md b/packages/agentic/desktop-driver/native/macos/README.md index e95aa487c9..b3b42acfc8 100644 --- a/packages/agentic/desktop-driver/native/macos/README.md +++ b/packages/agentic/desktop-driver/native/macos/README.md @@ -84,6 +84,24 @@ 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/application-fixture +``` + +The command prints the verified bundle/executable identity and window title. +The executable accepts `--title `; 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. + ## Signing ### Stable development signing diff --git a/packages/agentic/desktop-driver/native/macos/fixtures/ApplicationFixture.swift b/packages/agentic/desktop-driver/native/macos/fixtures/ApplicationFixture.swift new file mode 100644 index 0000000000..ef3bfd3b1f --- /dev/null +++ b/packages/agentic/desktop-driver/native/macos/fixtures/ApplicationFixture.swift @@ -0,0 +1,51 @@ +import AppKit + +@MainActor +final class ApplicationFixture: NSObject, NSApplicationDelegate { + private var window: NSWindow? + private var count = 0 + private let value = NSTextField(labelWithString: "Count: 0") + + func applicationDidFinishLaunching(_ notification: Notification) { + let arguments = ProcessInfo.processInfo.arguments + let titleIndex = arguments.firstIndex(of: "--title").map { $0 + 1 } + let title = titleIndex.flatMap { arguments.indices.contains($0) ? arguments[$0] : nil } + ?? "FURN Application Ownership Fixture" + let window = NSWindow( + contentRect: NSRect(x: 120, y: 120, width: 420, height: 240), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + window.title = title + window.identifier = NSUserInterfaceItemIdentifier("ownership-fixture-window") + let button = NSButton(title: "Increment", target: self, action: #selector(increment)) + button.frame = NSRect(x: 30, y: 120, width: 140, height: 32) + button.setAccessibilityIdentifier("ownership-fixture-increment") + value.frame = NSRect(x: 30, y: 80, width: 240, height: 24) + value.setAccessibilityIdentifier("ownership-fixture-count") + window.contentView?.addSubview(button) + window.contentView?.addSubview(value) + self.window = window + window.makeKeyAndOrderFront(nil) + NSApplication.shared.activate(ignoringOtherApps: true) + } + + @objc private func increment() { + count += 1 + value.stringValue = "Count: \(count)" + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> 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..a3dbc2cf81 --- /dev/null +++ b/packages/agentic/desktop-driver/native/macos/fixtures/build.cjs @@ -0,0 +1,45 @@ +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'; +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', '-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', + '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`); From c276f213ff5502914eb2afe74a91111b254391d8 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 12 Nov 2026 02:14:56 +0000 Subject: [PATCH 03/20] Implement neutral macOS application ownership and exact workload identity Record pending and acquired application claim resources, retain opt-in supervisor launches across sessions, and fail closed on ambiguous ownership. Preserve exact kernel incarnations through launch, attach, cancellation and cleanup, and exclude other helpers for the same application. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../neutral-macos-application-ownership.md | 15 + .../agentic/desktop-driver/native/PROTOCOL.md | 43 +- .../desktop-driver/native/macos/README.md | 17 +- .../DesktopDriverHost/Application.swift | 159 +++++-- .../ApplicationIncarnation.swift | 35 +- .../ApplicationIncarnationSelfTest.swift | 31 ++ .../DesktopDriverHost/ApplicationLock.swift | 59 +++ .../ApplicationTermination.swift | 27 +- .../ApplicationTerminationSelfTest.swift | 16 + .../Sources/DesktopDriverHost/Driver.swift | 5 +- .../Sources/DesktopDriverHost/Host.swift | 5 +- .../Sources/DesktopDriverHost/Support.swift | 1 + .../macos/fixtures/ApplicationFixture.swift | 7 + .../native/macos/fixtures/build.cjs | 8 +- .../desktop-driver/native/protocol.json | 18 + .../references/current-status.md | 2 +- .../agentic/desktop-driver/src/host/types.ts | 2 + .../hosts/native/NativeHostProcess.test.ts | 12 + .../src/hosts/native/NativeHostProcess.ts | 1 + .../desktop-driver/src/native/types.ts | 4 + packages/agentic/desktop-test/README.md | 8 +- packages/agentic/desktop-test/package.json | 5 + .../desktop-test/references/configuration.md | 14 + .../desktop-test/references/current-status.md | 4 +- .../desktop-test/references/lifecycle.md | 53 ++- .../references/native-qualification.md | 28 ++ packages/agentic/desktop-test/src/adapters.ts | 4 +- .../src/applicationOwnership.contract.test.ts | 430 ++++++++++++++++++ .../src/applicationOwnership.test.ts | 238 ++++++++++ .../desktop-test/src/applicationOwnership.ts | 246 ++++++++++ packages/agentic/desktop-test/src/config.ts | 35 +- .../desktop-test/src/contracts.contract.cjs | 33 +- .../desktop-test/src/ownership.test.ts | 91 ++++ .../agentic/desktop-test/src/ownership.ts | 78 +++- .../agentic/desktop-test/src/resources.ts | 11 + .../desktop-test/src/supervisor.test.ts | 7 + .../agentic/desktop-test/src/supervisor.ts | 32 +- .../desktop-test/src/wdio/configuration.ts | 2 +- 38 files changed, 1687 insertions(+), 99 deletions(-) create mode 100644 .changeset/neutral-macos-application-ownership.md create mode 100644 packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/ApplicationLock.swift create mode 100644 packages/agentic/desktop-test/src/applicationOwnership.contract.test.ts create mode 100644 packages/agentic/desktop-test/src/applicationOwnership.test.ts create mode 100644 packages/agentic/desktop-test/src/applicationOwnership.ts create mode 100644 packages/agentic/desktop-test/src/ownership.test.ts diff --git a/.changeset/neutral-macos-application-ownership.md b/.changeset/neutral-macos-application-ownership.md new file mode 100644 index 0000000000..36cced6282 --- /dev/null +++ b/.changeset/neutral-macos-application-ownership.md @@ -0,0 +1,15 @@ +--- +'@fluentui-react-native/desktop-driver': patch +'@fluentui-react-native/desktop-test': patch +'@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. diff --git a/packages/agentic/desktop-driver/native/PROTOCOL.md b/packages/agentic/desktop-driver/native/PROTOCOL.md index eb19273e4f..cfe22e6b74 100644 --- a/packages/agentic/desktop-driver/native/PROTOCOL.md +++ b/packages/agentic/desktop-driver/native/PROTOCOL.md @@ -125,6 +125,34 @@ 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. + +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. 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 that has created a process reports its +captured `processId`, `processStartedAt` when readable, `launchKind`, and +`applicationSettlement: "exited" | "unknown"`. This metadata appears in +`response.error.data` or `cancelled.data`. `exited` requires exact cleanup proof; +an unreadable or surviving incarnation is `unknown`. 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. 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 +554,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 +628,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 b3b42acfc8..f0938c85ea 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 | @@ -95,7 +96,8 @@ node native/macos/fixtures/build.cjs artifacts/application-fixture ``` The command prints the verified bundle/executable identity and window title. -The executable accepts `--title `; its Increment button +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 @@ -188,8 +190,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 @@ -197,6 +202,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 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..99ed3b69ae 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 { @@ -307,6 +320,9 @@ final class ApplicationManager { func launch(_ params: JSONObject, token: CancellationToken) 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 +349,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 +363,12 @@ final class ApplicationManager { "The selected application bundle does not have the expected bundle identifier \"\(bundleIdentifier)\"." ) } - running = try launchApplication( + 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, @@ -369,17 +389,25 @@ final class ApplicationManager { try fail(ErrorCode.launchFailed, "Starting the application failed: \(error.localizedDescription)") } let processID = process.processIdentifier + guard let identity = try? ServiceIdentity.inspect(processID) 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.startedAt + 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 +419,21 @@ final class ApplicationManager { try fail(ErrorCode.launchFailed, "The application could not be resolved for launch.") } + let exclusion: ExactApplicationLock + do { + exclusion = try ExactApplicationLock.acquire(processID: running.processIdentifier, startedAt: startedAt) + } catch { + // Another helper may already control this process. Do not signal through its exclusion lock. + throw HelperError(code: ErrorCode.launchFailed, message: "The launched application could not be exclusively owned.", + data: ["processId": Int(running.processIdentifier), "processStartedAt": startedAt, + "launchKind": launchKind, "applicationSettlement": "unknown"]) + } 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,23 +451,31 @@ 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 + private func abandonedLaunch(processID: pid_t, startedAt: String?, launchKind: String, error: Error) -> Error { + let exited = terminateUnleased(processID: processID, startedAt: startedAt, bounds: .abandoned) + var data: JSONObject = ["processId": Int(processID), "launchKind": launchKind, + "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: "The launch failed and the application process it started did not exit.", - data: ["processId": Int(processID)] + 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 ) } @@ -457,6 +507,32 @@ 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 try createLease(ownership: "attached", running: running, windowTitle: windowTitle, params: params, + expectedStartedAt: expected) + } + if let leasePath = optionalString(application, "leasePath") { let lease = try readLease(path: leasePath, token: token) guard integer(lease, "schemaVersion") == 1 else { @@ -715,7 +791,8 @@ final class ApplicationManager { processID: lease.processID, startedAt: lease.processStartedAt, bounds: .close, - token: token + token: token, + exactStartedAt: lease.exactStartedAt ) } removeLease(leaseID) @@ -725,9 +802,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 +987,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 +1007,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 +1159,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 +1170,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,7 +1188,7 @@ 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 @@ -1114,16 +1197,18 @@ final class ApplicationManager { let result = LaunchResultBox() let semaphore = DispatchSemaphore(value: 0) NSWorkspace.shared.openApplication(at: url, configuration: configuration) { application, error in + let startedAt = application.flatMap { try? ServiceIdentity.inspect($0.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,15 +1225,17 @@ 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) } 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)") @@ -1156,7 +1243,11 @@ final class ApplicationManager { guard let application else { try fail(ErrorCode.launchFailed, "Launch Services did not return the launched application.") } - return application + 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, 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..d3829d2e1f 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,34 @@ 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 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..39b54b236c 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,15 @@ 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") func answer(_ text: String?) -> JSONObject { ApplicationIncarnation.handle(text.map { Data($0.utf8) }) @@ -31,5 +40,27 @@ 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() + } + + 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(true, "two application owners cannot acquire the same exact incarnation") + } + 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..2d1f744dc9 --- /dev/null +++ b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/ApplicationLock.swift @@ -0,0 +1,59 @@ +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 { + close(fd) + try fail(ErrorCode.attachFailed, "Another desktop service controls this exact application incarnation.") + } + return ExactApplicationLock(fd) + } + + 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..ed90ad497d 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)") @@ -129,3 +137,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..65dc69d4bd 100644 --- a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/ApplicationTerminationSelfTest.swift +++ b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/ApplicationTerminationSelfTest.swift @@ -129,6 +129,22 @@ 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() } 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..de9f25b7ab 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 { 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..9555c787d9 100644 --- a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Host.swift +++ b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Host.swift @@ -289,7 +289,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 +371,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 +379,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/Support.swift b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Support.swift index e1d2ec040c..08fdc9a008 100644 --- a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Support.swift +++ b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Support.swift @@ -112,6 +112,7 @@ struct HelperError: Error, CustomStringConvertible { struct CancelledError: Error { var effect: MutationEffect? + var data: JSONObject? } @inline(__always) diff --git a/packages/agentic/desktop-driver/native/macos/fixtures/ApplicationFixture.swift b/packages/agentic/desktop-driver/native/macos/fixtures/ApplicationFixture.swift index ef3bfd3b1f..ebfe74d83a 100644 --- a/packages/agentic/desktop-driver/native/macos/fixtures/ApplicationFixture.swift +++ b/packages/agentic/desktop-driver/native/macos/fixtures/ApplicationFixture.swift @@ -29,6 +29,13 @@ final class ApplicationFixture: NSObject, NSApplicationDelegate { self.window = window window.makeKeyAndOrderFront(nil) NSApplication.shared.activate(ignoringOtherApps: true) + if let readyIndex = arguments.firstIndex(of: "--ready-file").map({ $0 + 1 }), + arguments.indices.contains(readyIndex) + { + try? String(ProcessInfo.processInfo.processIdentifier).write( + toFile: arguments[readyIndex], atomically: true, encoding: .utf8 + ) + } } @objc private func increment() { diff --git a/packages/agentic/desktop-driver/native/macos/fixtures/build.cjs b/packages/agentic/desktop-driver/native/macos/fixtures/build.cjs index a3dbc2cf81..15ca013a31 100644 --- a/packages/agentic/desktop-driver/native/macos/fixtures/build.cjs +++ b/packages/agentic/desktop-driver/native/macos/fixtures/build.cjs @@ -34,12 +34,6 @@ fs.writeFileSync( '', ].join('\n'), ); -run('codesign', [ - '--force', - '--sign', - process.env.FURN_DESKTOP_DRIVER_MACOS_SIGNING_IDENTITY || '-', - '--timestamp=none', - bundle, -]); +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..09955970ea 100644 --- a/packages/agentic/desktop-driver/native/protocol.json +++ b/packages/agentic/desktop-driver/native/protocol.json @@ -9,5 +9,23 @@ "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$" + } + } + }, + "launchKind": ["launch-services", "direct-child"], + "containment": ["uncontained", "external"], + "applicationSettlement": ["exited", "unknown"], + "failureMetadataLocations": ["response.error.data", "cancelled.data"] } } diff --git a/packages/agentic/desktop-driver/references/current-status.md b/packages/agentic/desktop-driver/references/current-status.md index 507829248c..63afbb23c2 100644 --- a/packages/agentic/desktop-driver/references/current-status.md +++ b/packages/agentic/desktop-driver/references/current-status.md @@ -23,7 +23,7 @@ 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. macOS exact-application flock exclusion is implemented; positive real-app exclusion and input-mutex qualification remain blocked by Accessibility/PostEvent permissions. 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. | | Mutation effects, settlement, quarantine | Done | Native mutations settle before advancing. Uncertain story changes now quarantine instead of assuming a later render is safe. | 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/NativeHostProcess.test.ts b/packages/agentic/desktop-driver/src/hosts/native/NativeHostProcess.test.ts index 6a045f918f..69ff5c5467 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,18 @@ 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' }; + 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('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..2a7bac76c2 100644 --- a/packages/agentic/desktop-driver/src/hosts/native/NativeHostProcess.ts +++ b/packages/agentic/desktop-driver/src/hosts/native/NativeHostProcess.ts @@ -334,6 +334,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; diff --git a/packages/agentic/desktop-driver/src/native/types.ts b/packages/agentic/desktop-driver/src/native/types.ts index 3a9789f5e9..3df62a3ded 100644 --- a/packages/agentic/desktop-driver/src/native/types.ts +++ b/packages/agentic/desktop-driver/src/native/types.ts @@ -62,6 +62,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?: Readonly<{ processId: number; processStartedAt: string }>; windowTitle?: string; workingDirectory?: string; }; @@ -119,6 +121,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?: Record; id: string; type: 'cancelled'; }; diff --git a/packages/agentic/desktop-test/README.md b/packages/agentic/desktop-test/README.md index 1e1a15a591..980898b12b 100644 --- a/packages/agentic/desktop-test/README.md +++ b/packages/agentic/desktop-test/README.md @@ -61,9 +61,11 @@ when working on the package. - App targets and suites share one structural configuration schema. - WDIO owns suite sessions, globals, hooks, grouped execution, retries, and framework assertions. -- 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's lifecycle + takeover remains transitional (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/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 2b58da4e1a..1ce5da397c 100644 --- a/packages/agentic/desktop-test/references/current-status.md +++ b/packages/agentic/desktop-test/references/current-status.md @@ -31,7 +31,7 @@ and its sub-issues. | 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 | Must await drain/deletion; attached-app handoff is independent of future supervisor-owned launch. | -| 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. | +| Supervisor-owned app lifetime | Work in progress | Opt-in macOS supervisor launch, neutral exact attach, native causal ownership and application claim resources are implemented. Session-owned launch remains the default; external attach never transfers ownership. Positive AppKit/Fabric/Paper lifecycle qualification is blocked by Accessibility permissions; descendants and interrupted publication remain gates. | | 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. | | 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. | @@ -69,7 +69,7 @@ did not retain a portable raw-report locator; do not imply otherwise. | 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. | +| 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; 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. | diff --git a/packages/agentic/desktop-test/references/lifecycle.md b/packages/agentic/desktop-test/references/lifecycle.md index 59817017f9..f768136839 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 @@ -96,17 +99,47 @@ 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 stays unresolved unless the +provider explicitly proves its exact process exited. Advisory exit events, +duplicate lease IDs, a helper exit, and service replacement never settle it. + +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 7d0ae7dfbd..bc6980a97b 100644 --- a/packages/agentic/desktop-test/references/native-qualification.md +++ b/packages/agentic/desktop-test/references/native-qualification.md @@ -22,6 +22,34 @@ 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 +[macOS provider README](../../desktop-driver/native/macos/README.md). +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. 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. + +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. + ## 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..939dcc99ff --- /dev/null +++ b/packages/agentic/desktop-test/src/applicationOwnership.contract.test.ts @@ -0,0 +1,430 @@ +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)) } : {}), + }; +} + +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({ platform: 'macos', fixture: 'appkit', 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 owner = new DesktopSupervisor({ + target: resolved, + reporter, + reportSession: { reporter, delegated: true, environment: {}, close: async () => 0 }, + lockDirectory, + processIdentity: await describeDesktopWorkloadOwner({ helperPath: artifact.executablePath }), + hostFactory: async () => nativeHost(descriptor), + recovery: { root: path.join(directory, `${name}-recovery`), probeApplication: createMacOSApplicationProbe({ artifact }) }, + }); + return { owner, 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('supervisor launch survives two real sessions, records native owner/application identity, and closes exactly', async () => { + const { owner, 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$/); + 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('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')); + await expect(secondClient.newSession(capabilities('external-second'))).rejects.toThrow(/Another desktop service controls/); + 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('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), + }, + ); + try { + const failure = await host.launch({ ...target, host }).catch((error: unknown) => error); + 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), + }); + } finally { + await 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..c3631942c5 --- /dev/null +++ b/packages/agentic/desktop-test/src/applicationOwnership.test.ts @@ -0,0 +1,238 @@ +import type { ApplicationLease, DesktopHost, DesktopTarget } from '@fluentui-react-native/desktop-driver'; + +import { + captureDesktopApplicationIdentity, + createDesktopApplicationHost, + desktopApplicationIdentityDescriptor, +} from './applicationOwnership'; +import type { DesktopResourceRecord } from './resources'; + +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('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('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(() => + 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..d67f508d31 --- /dev/null +++ b/packages/agentic/desktop-test/src/applicationOwnership.ts @@ -0,0 +1,246 @@ +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 (data?.applicationSettlement === 'exited') entry.uncertain = false; + 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 ba3781270a..28d9a38d94 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; @@ -1933,7 +1948,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 { @@ -2108,6 +2127,7 @@ async function main() { return launch(...args); }; const supervisor = new DesktopSupervisor({ + processIdentity: fixtureProcessIdentity, target, recovery: memoryRecovery(), reporter: run, @@ -2151,6 +2171,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/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/desktop-test/src/wdio/configuration.ts b/packages/agentic/desktop-test/src/wdio/configuration.ts index bac03c8fb8..268558ef2c 100644 --- a/packages/agentic/desktop-test/src/wdio/configuration.ts +++ b/packages/agentic/desktop-test/src/wdio/configuration.ts @@ -15,7 +15,7 @@ export type DesktopWdioSettings = { targetId: string; endpoint: DesktopEndpoint; renderer?: DesktopRenderer; - ownership: 'session' | 'external'; + ownership: 'session' | 'supervisor' | 'external'; specs: (string | string[])[]; framework: string; reporter: string; From f835830046b447ba0a49e28b74fd7d7213af2480 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 12 Nov 2026 02:24:22 +0000 Subject: [PATCH 04/20] Harden causal application capture and native failure evidence Keep exact process capture bound to the causal child or LaunchServices application, retain bounded native error metadata, and cover Windows/Win32 application-survivor claims. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentic/desktop-driver/native/PROTOCOL.md | 7 ++ .../DesktopDriverHost/Application.swift | 50 +++++++++-- .../ApplicationIncarnation.swift | 7 ++ .../ApplicationIncarnationSelfTest.swift | 12 +++ .../desktop-driver/native/protocol.json | 6 ++ .../src/applicationOwnership.contract.test.ts | 85 +++++++++++++------ .../src/applicationOwnership.test.ts | 57 +++++++++++++ 7 files changed, 192 insertions(+), 32 deletions(-) diff --git a/packages/agentic/desktop-driver/native/PROTOCOL.md b/packages/agentic/desktop-driver/native/PROTOCOL.md index cfe22e6b74..99a4206ace 100644 --- a/packages/agentic/desktop-driver/native/PROTOCOL.md +++ b/packages/agentic/desktop-driver/native/PROTOCOL.md @@ -132,6 +132,9 @@ launched lease additionally reports `launchKind: "launch-services" | `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`. @@ -150,6 +153,10 @@ captured `processId`, `processStartedAt` when readable, `launchKind`, and an unreadable or surviving incarnation is `unknown`. 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. 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. 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 99ed3b69ae..975243a98d 100644 --- a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Application.swift +++ b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Application.swift @@ -389,11 +389,14 @@ final class ApplicationManager { try fail(ErrorCode.launchFailed, "Starting the application failed: \(error.localizedDescription)") } let processID = process.processIdentifier - guard let identity = try? ServiceIdentity.inspect(processID) else { + 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.startedAt + startedAt = identity launchKind = "direct-child" let registered: NSRunningApplication? do { @@ -465,8 +468,10 @@ final class ApplicationManager { /// exit has no lease to keep it visible, so the failure names it instead of hiding the survivor. private func abandonedLaunch(processID: pid_t, startedAt: String?, launchKind: String, error: Error) -> Error { let exited = terminateUnleased(processID: processID, startedAt: startedAt, bounds: .abandoned) - var data: JSONObject = ["processId": Int(processID), "launchKind": launchKind, - "applicationSettlement": exited ? "exited" : "unknown"] + 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) @@ -1194,10 +1199,19 @@ final class ApplicationManager { 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 { try? ServiceIdentity.inspect($0.processIdentifier).startedAt } + 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 @@ -1230,6 +1244,15 @@ final class ApplicationManager { if let application, !preexisting.contains(application.processIdentifier) { 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() @@ -1238,10 +1261,23 @@ final class ApplicationManager { 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": "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", 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 d3829d2e1f..2a3c3dc143 100644 --- a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/ApplicationIncarnation.swift +++ b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/ApplicationIncarnation.swift @@ -84,6 +84,13 @@ enum ApplicationIncarnation { } } + 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 { switch observed { case .gone: 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 39b54b236c..c255a28728 100644 --- a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/ApplicationIncarnationSelfTest.swift +++ b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/ApplicationIncarnationSelfTest.swift @@ -22,6 +22,18 @@ extension SelfTestRunner { "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) }) diff --git a/packages/agentic/desktop-driver/native/protocol.json b/packages/agentic/desktop-driver/native/protocol.json index 09955970ea..6a2641cf5d 100644 --- a/packages/agentic/desktop-driver/native/protocol.json +++ b/packages/agentic/desktop-driver/native/protocol.json @@ -26,6 +26,12 @@ "launchKind": ["launch-services", "direct-child"], "containment": ["uncontained", "external"], "applicationSettlement": ["exited", "unknown"], + "launchServicesErrorFields": { + "launchErrorDomain": { "type": "string", "maxLength": 160 }, + "launchErrorCode": { "type": "integer" }, + "underlyingErrorDomain": { "type": "string", "maxLength": 160 }, + "underlyingErrorCode": { "type": "integer" } + }, "failureMetadataLocations": ["response.error.data", "cancelled.data"] } } diff --git a/packages/agentic/desktop-test/src/applicationOwnership.contract.test.ts b/packages/agentic/desktop-test/src/applicationOwnership.contract.test.ts index 939dcc99ff..b9db79c522 100644 --- a/packages/agentic/desktop-test/src/applicationOwnership.contract.test.ts +++ b/packages/agentic/desktop-test/src/applicationOwnership.contract.test.ts @@ -60,6 +60,20 @@ function errorDetails(error: unknown, depth = 0): unknown { }; } +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'); @@ -79,7 +93,26 @@ afterAll(() => { if (!directory) return; fs.writeFileSync( path.join(directory, 'evidence.json'), - JSON.stringify({ platform: 'macos', fixture: 'appkit', helperSigning: artifact?.signing, cases: evidence }, null, 2), + 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, + ), ); }); @@ -372,30 +405,32 @@ for (const launchKind of ['direct-child', 'launch-services'] as const) { onResource: (record) => records.push(record), }, ); - try { - const failure = await host.launch({ ...target, host }).catch((error: unknown) => error); - 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), - }); - } finally { - await host.dispose(); - } + 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(), + ); }); } diff --git a/packages/agentic/desktop-test/src/applicationOwnership.test.ts b/packages/agentic/desktop-test/src/applicationOwnership.test.ts index c3631942c5..34ed3caf68 100644 --- a/packages/agentic/desktop-test/src/applicationOwnership.test.ts +++ b/packages/agentic/desktop-test/src/applicationOwnership.test.ts @@ -1,3 +1,6 @@ +import fs from 'node:fs'; +import path from 'node:path'; + import type { ApplicationLease, DesktopHost, DesktopTarget } from '@fluentui-react-native/desktop-driver'; import { @@ -6,6 +9,7 @@ import { desktopApplicationIdentityDescriptor, } from './applicationOwnership'; import type { DesktopResourceRecord } from './resources'; +import { createDesktopWorkloadClaim } from './ownership'; const owned: ApplicationLease = { id: 'lease-1', @@ -136,6 +140,58 @@ describe('neutral desktop application ownership', () => { expect(f.records.at(-1)?.disposal).toBe('failed'); }); + 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; @@ -231,6 +287,7 @@ describe('neutral desktop application ownership', () => { 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/); From 56e73b669be94d85daee75e31a93375c89f0f005 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 12 Nov 2026 02:36:40 +0000 Subject: [PATCH 05/20] Pin the native ownership fixture to a compatible macOS deployment target LaunchServices rejected the inherited macOS 28.0 minimum on this macOS 27.2.1 machine. Align the fixture compiler/bundle minimum with the helper, import the new public neutral entry in its consumer fixture, and account for bounded diagnostic truncation markers in the protocol schema. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentic/desktop-driver/native/macos/README.md | 4 +++- .../desktop-driver/native/macos/fixtures/build.cjs | 14 +++++++++++++- .../agentic/desktop-driver/native/protocol.json | 4 ++-- .../neutral-boundary/standalone-consumer.mjs | 1 + 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/agentic/desktop-driver/native/macos/README.md b/packages/agentic/desktop-driver/native/macos/README.md index f0938c85ea..cf10b35c1e 100644 --- a/packages/agentic/desktop-driver/native/macos/README.md +++ b/packages/agentic/desktop-driver/native/macos/README.md @@ -92,10 +92,12 @@ Storybook and React Native. From this package, build it into an ignored artifact directory: ```sh -node native/macos/fixtures/build.cjs artifacts/application-fixture +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 diff --git a/packages/agentic/desktop-driver/native/macos/fixtures/build.cjs b/packages/agentic/desktop-driver/native/macos/fixtures/build.cjs index 15ca013a31..62b084cece 100644 --- a/packages/agentic/desktop-driver/native/macos/fixtures/build.cjs +++ b/packages/agentic/desktop-driver/native/macos/fixtures/build.cjs @@ -9,6 +9,7 @@ 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) { @@ -17,7 +18,17 @@ function run(command, args) { if (result.status !== 0) throw new Error(`${command} failed: ${result.stderr || result.stdout}`); } -run('xcrun', ['swiftc', '-parse-as-library', '-framework', 'AppKit', path.join(__dirname, 'ApplicationFixture.swift'), '-o', executable]); +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'), [ @@ -29,6 +40,7 @@ fs.writeFileSync( 'CFBundleExecutablefixture', 'CFBundlePackageTypeAPPL', 'CFBundleVersion1', + `LSMinimumSystemVersion${minimumMacOSVersion}`, 'NSPrincipalClassNSApplication', '', '', diff --git a/packages/agentic/desktop-driver/native/protocol.json b/packages/agentic/desktop-driver/native/protocol.json index 6a2641cf5d..cd94c229f7 100644 --- a/packages/agentic/desktop-driver/native/protocol.json +++ b/packages/agentic/desktop-driver/native/protocol.json @@ -27,9 +27,9 @@ "containment": ["uncontained", "external"], "applicationSettlement": ["exited", "unknown"], "launchServicesErrorFields": { - "launchErrorDomain": { "type": "string", "maxLength": 160 }, + "launchErrorDomain": { "type": "string", "maxLength": 163 }, "launchErrorCode": { "type": "integer" }, - "underlyingErrorDomain": { "type": "string", "maxLength": 160 }, + "underlyingErrorDomain": { "type": "string", "maxLength": 163 }, "underlyingErrorCode": { "type": "integer" } }, "failureMetadataLocations": ["response.error.data", "cancelled.data"] 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'), }; From 58b2cd5d1f6554412bf8fdefcd9f7120e38caa76 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 12 Nov 2026 02:43:23 +0000 Subject: [PATCH 06/20] Record bounded macOS application ownership qualification and remaining gates Record frozen-source native identity, rejected direct-child and LaunchServices cleanup, and portable claim/survivor regressions. Preserve separate Accessibility-blocked positive lifecycle, Fabric/Paper, descendant, physical mutex and Windows native survivor gates. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../references/current-status.md | 48 +++++++++- .../desktop-test/references/current-status.md | 96 ++++++++++++++++--- .../references/native-qualification.md | 21 ++++ 3 files changed, 152 insertions(+), 13 deletions(-) diff --git a/packages/agentic/desktop-driver/references/current-status.md b/packages/agentic/desktop-driver/references/current-status.md index 63afbb23c2..fe50116b08 100644 --- a/packages/agentic/desktop-driver/references/current-status.md +++ b/packages/agentic/desktop-driver/references/current-status.md @@ -7,7 +7,7 @@ owner_paths: # Current capability and qualification status -**Work in progress, updated 2026-11-08.** "Done" below means the stated +**Work in progress, updated 2026-11-12.** "Done" below means the stated implementation exists, not that every native endpoint is qualified or that the complete orchestration product is ready for submission. Historical measurements remain tied to their original revision; they were not rerun by this update. @@ -23,7 +23,7 @@ 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. macOS exact-application flock exclusion is implemented; positive real-app exclusion and input-mutex qualification remain blocked by Accessibility/PostEvent permissions. Windows behavior is unchanged. | +| Native sessions and command/input queues | Done | One reservation per registered target in one service. macOS exact-application flock exclusion is implemented; positive real-app exclusion and input-mutex qualification remain blocked by Accessibility/PostEvent permissions. 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. | | Mutation effects, settlement, quarantine | Done | Native mutations settle before advancing. Uncertain story changes now quarantine instead of assuming a later render is safe. | @@ -472,3 +472,47 @@ after the disconnect, so fresh-preview fencing remains Partial. The first 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 the +[harness evidence ledger](../../desktop-test/references/current-status.md#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. diff --git a/packages/agentic/desktop-test/references/current-status.md b/packages/agentic/desktop-test/references/current-status.md index 1ce5da397c..0ded2152fe 100644 --- a/packages/agentic/desktop-test/references/current-status.md +++ b/packages/agentic/desktop-test/references/current-status.md @@ -7,7 +7,7 @@ owner_paths: # Current lifecycle and qualification status -**Work in progress, updated 2026-11-08.** Done means the listed implementation, +**Work in progress, updated 2026-11-12.** Done means the listed implementation, not full cross-platform qualification. This package is the intended neutral workload host; the existing Storybook lifecycle takeover is transitional. No new coordinator package, daemon, dynamic plugin loader, or package rename @@ -31,8 +31,8 @@ and its sub-issues. | 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 | Must await drain/deletion; attached-app handoff is independent of future supervisor-owned launch. | -| Supervisor-owned app lifetime | Work in progress | Opt-in macOS supervisor launch, neutral exact attach, native causal ownership and application claim resources are implemented. Session-owned launch remains the default; external attach never transfers ownership. Positive AppKit/Fabric/Paper lifecycle qualification is blocked by Accessibility permissions; descendants and interrupted publication remain gates. | -| 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. | +| Supervisor-owned app lifetime | Work in progress | Opt-in macOS supervisor launch, neutral exact attach, native causal ownership and application claim resources are implemented. Session-owned launch remains the default; external attach never transfers ownership. Native OS-owner inspection and failed direct-child/LaunchServices primary-process cleanup passed. Positive application qualification is blocked by Accessibility permissions; Fabric/Paper, descendants and interrupted publication remain gates. | +| Cross-service/multi-target claims | Work in progress | Configured aliases retain physical identity. Exact macOS application exclusion is implemented and self-tested; positive independent-service application and physical-input mutex qualification remain 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. 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; 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. | +| Area | Remaining evidence/work | +| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| macOS | Positive owned/external application lifecycle, live failed-cleanup and service-restart cases; Fabric/Paper separately; supervisor death with Metro/channel/helper survivors; exact attached-app preservation. Native OS-owner inspection and failed direct-child/LaunchServices primary cleanup are supporting evidence only. | +| 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); @@ -1026,3 +1026,77 @@ macOS Accessibility block above remains unchanged; no local smoke was 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); +the pending resource correctly stayed uncertain. 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. diff --git a/packages/agentic/desktop-test/references/native-qualification.md b/packages/agentic/desktop-test/references/native-qualification.md index bc6980a97b..15190579b7 100644 --- a/packages/agentic/desktop-test/references/native-qualification.md +++ b/packages/agentic/desktop-test/references/native-qualification.md @@ -42,6 +42,17 @@ cancellation. Its evidence is written to the desktop-test package's 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 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 @@ -50,6 +61,16 @@ 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 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: From 5df4b9d593a18079181971e5492f164c5872721d Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 12 Nov 2026 02:48:11 +0000 Subject: [PATCH 07/20] Keep package evidence references within guidance-link boundaries Use explicit code paths for cross-package evidence and provider references, as required by check-agent-instructions. Native qualification remains tied to its recorded frozen source. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/agentic/desktop-driver/references/current-status.md | 4 ++-- .../agentic/desktop-test/references/native-qualification.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/agentic/desktop-driver/references/current-status.md b/packages/agentic/desktop-driver/references/current-status.md index fe50116b08..8df7020cf1 100644 --- a/packages/agentic/desktop-driver/references/current-status.md +++ b/packages/agentic/desktop-driver/references/current-status.md @@ -507,8 +507,8 @@ temporary storage and an isolated helper cache: is macOS 11.0, matching the helper. Exact helper artifact/digest, commands, PIDs, failures, cleanup and evidence -locators are in the -[harness evidence ledger](../../desktop-test/references/current-status.md#2026-11-12-f10-m-neutral-application-ownership). +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. diff --git a/packages/agentic/desktop-test/references/native-qualification.md b/packages/agentic/desktop-test/references/native-qualification.md index 15190579b7..6e8426f187 100644 --- a/packages/agentic/desktop-test/references/native-qualification.md +++ b/packages/agentic/desktop-test/references/native-qualification.md @@ -26,7 +26,7 @@ commands below from `apps/storybook`, where their scripts are declared. The package's opt-in AppKit contract builds the signed native helper and the isolated fixture described in the -[macOS provider README](../../desktop-driver/native/macos/README.md). +`packages/agentic/desktop-driver/native/macos/README.md` provider guide. From the repository root, under the exclusive native-validation lock: ```sh From ddeb328e27692ea0d2c33d0fabf2976d3dd0467c Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 12 Nov 2026 02:54:32 +0000 Subject: [PATCH 08/20] Record final locked desktop ownership aggregate validation The corrected clean-cache buildci run passed 176 tasks with two cache skips and no failures. Record its exact source, environment and the prior owned guidance-link correction without promoting blocked native gates to passes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../desktop-test/references/current-status.md | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/agentic/desktop-test/references/current-status.md b/packages/agentic/desktop-test/references/current-status.md index 0ded2152fe..24124d720a 100644 --- a/packages/agentic/desktop-test/references/current-status.md +++ b/packages/agentic/desktop-test/references/current-status.md @@ -1100,3 +1100,28 @@ 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. + +### Final 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. From ff612646ee6a1b3dfa480f935aaff04bdc279f01 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 12 Nov 2026 03:12:00 +0000 Subject: [PATCH 09/20] Settle rejected launches without leaking workload claims Distinguish not-started acquisitions from uncertain dispatch, preserve launched applications only on actual lock contention, and version the async claim API as a minor release. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../neutral-macos-application-ownership.md | 6 +- .../agentic/desktop-driver/native/PROTOCOL.md | 24 ++++-- .../DesktopDriverHost/Application.swift | 57 +++++++------- .../ApplicationIncarnationSelfTest.swift | 3 +- .../DesktopDriverHost/ApplicationLock.swift | 30 +++++++- .../ApplicationTermination.swift | 19 +++++ .../ApplicationTerminationSelfTest.swift | 64 ++++++++++++++++ .../desktop-driver/native/protocol.json | 2 +- .../hosts/native/NativeHostProcess.test.ts | 25 ++++++- .../src/hosts/native/NativeHostProcess.ts | 15 +++- .../desktop-driver/src/native/types.ts | 11 ++- .../desktop-test/references/lifecycle.md | 9 ++- .../references/native-qualification.md | 3 +- .../src/applicationOwnership.contract.test.ts | 37 +++++++++ .../src/applicationOwnership.test.ts | 75 +++++++++++++++++++ .../desktop-test/src/applicationOwnership.ts | 15 +++- 16 files changed, 343 insertions(+), 52 deletions(-) diff --git a/.changeset/neutral-macos-application-ownership.md b/.changeset/neutral-macos-application-ownership.md index 36cced6282..dcda6b7204 100644 --- a/.changeset/neutral-macos-application-ownership.md +++ b/.changeset/neutral-macos-application-ownership.md @@ -1,6 +1,6 @@ --- '@fluentui-react-native/desktop-driver': patch -'@fluentui-react-native/desktop-test': patch +'@fluentui-react-native/desktop-test': minor '@fluentui-react-native/storybook-desktop': patch --- @@ -13,3 +13,7 @@ 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. diff --git a/packages/agentic/desktop-driver/native/PROTOCOL.md b/packages/agentic/desktop-driver/native/PROTOCOL.md index 99a4206ace..4b8656cce8 100644 --- a/packages/agentic/desktop-driver/native/PROTOCOL.md +++ b/packages/agentic/desktop-driver/native/PROTOCOL.md @@ -143,17 +143,25 @@ 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. Explicit app paths are authoritative; bundle-only launch rejects +released. Lock contention never permits signalling another helper's application. +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 that has created a process reports its -captured `processId`, `processStartedAt` when readable, `launchKind`, and -`applicationSettlement: "exited" | "unknown"`. This metadata appears in -`response.error.data` or `cancelled.data`. `exited` requires exact cleanup proof; -an unreadable or surviving incarnation is `unknown`. A pending launch whose +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. These -failures may additionally include bounded `launchErrorDomain`, `launchErrorCode`, +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 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 975243a98d..968cbd4590 100644 --- a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Application.swift +++ b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Application.swift @@ -319,6 +319,27 @@ 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.") @@ -363,6 +384,7 @@ final class ApplicationManager { "The selected application bundle does not have the expected bundle identifier \"\(bundleIdentifier)\"." ) } + didDispatch() let launched = try launchApplication( at: appURL, arguments: arguments, preexisting: preexisting, timeoutMilliseconds: launchTimeout, token: token ) @@ -388,6 +410,7 @@ 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 }, @@ -422,15 +445,9 @@ final class ApplicationManager { try fail(ErrorCode.launchFailed, "The application could not be resolved for launch.") } - let exclusion: ExactApplicationLock - do { - exclusion = try ExactApplicationLock.acquire(processID: running.processIdentifier, startedAt: startedAt) - } catch { - // Another helper may already control this process. Do not signal through its exclusion lock. - throw HelperError(code: ErrorCode.launchFailed, message: "The launched application could not be exclusively owned.", - data: ["processId": Int(running.processIdentifier), "processStartedAt": startedAt, - "launchKind": launchKind, "applicationSettlement": "unknown"]) - } + let exclusion = try ExactApplicationLock.acquireLaunched( + processID: running.processIdentifier, startedAt: startedAt, launchKind: launchKind + ) do { guard ApplicationIncarnation.classifyExact( recorded: startedAt, observed: ApplicationIncarnation.observeExact(running.processIdentifier) @@ -464,26 +481,6 @@ final class ApplicationManager { } } - /// 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, 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 - ) - } - /// 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( @@ -1262,7 +1259,7 @@ final class ApplicationManager { result.lock.unlock() if let error { let failure = error as NSError - var data: JSONObject = ["launchKind": "launch-services", "applicationSettlement": "unknown", + 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) 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 c255a28728..e79f77cdf6 100644 --- a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/ApplicationIncarnationSelfTest.swift +++ b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/ApplicationIncarnationSelfTest.swift @@ -65,7 +65,8 @@ extension SelfTestRunner { unexpected.release() check(false, "two application owners cannot acquire the same exact incarnation") } catch { - check(true, "two application owners cannot acquire the same exact incarnation") + 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) diff --git a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/ApplicationLock.swift b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/ApplicationLock.swift index 2d1f744dc9..09cde5dbb8 100644 --- a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/ApplicationLock.swift +++ b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/ApplicationLock.swift @@ -42,12 +42,40 @@ final class ExactApplicationLock: @unchecked Sendable { 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) - try fail(ErrorCode.attachFailed, "Another desktop service controls this exact application incarnation.") + 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) 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 ed90ad497d..904247d339 100644 --- a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/ApplicationTermination.swift +++ b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/ApplicationTermination.swift @@ -129,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 { 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 65dc69d4bd..f8c1bf11cb 100644 --- a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/ApplicationTerminationSelfTest.swift +++ b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/ApplicationTerminationSelfTest.swift @@ -147,6 +147,70 @@ extension SelfTestRunner { } 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/protocol.json b/packages/agentic/desktop-driver/native/protocol.json index cd94c229f7..c806994e0b 100644 --- a/packages/agentic/desktop-driver/native/protocol.json +++ b/packages/agentic/desktop-driver/native/protocol.json @@ -25,7 +25,7 @@ }, "launchKind": ["launch-services", "direct-child"], "containment": ["uncontained", "external"], - "applicationSettlement": ["exited", "unknown"], + "applicationSettlement": ["not-started", "exited", "unknown"], "launchServicesErrorFields": { "launchErrorDomain": { "type": "string", "maxLength": 163 }, "launchErrorCode": { "type": "integer" }, 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 69ff5c5467..744ebb30ef 100644 --- a/packages/agentic/desktop-driver/src/hosts/native/NativeHostProcess.test.ts +++ b/packages/agentic/desktop-driver/src/hosts/native/NativeHostProcess.test.ts @@ -110,7 +110,7 @@ describe('NativeHostProcess mutation effects', () => { }); test('preserves application settlement evidence from a cancelled launch', async () => { - const data = { processId: 123, processStartedAt: '2026-11-12T00:00:00.123456Z', applicationSettlement: 'unknown' }; + 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' }), }); @@ -121,6 +121,29 @@ describe('NativeHostProcess mutation effects', () => { 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 2a7bac76c2..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, }; @@ -538,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/native/types.ts b/packages/agentic/desktop-driver/src/native/types.ts index 3df62a3ded..b6cf48f0ab 100644 --- a/packages/agentic/desktop-driver/src/native/types.ts +++ b/packages/agentic/desktop-driver/src/native/types.ts @@ -86,6 +86,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; @@ -96,7 +103,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; @@ -122,7 +129,7 @@ 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?: Record; + data?: NativeHostFailureData; id: string; type: 'cancelled'; }; diff --git a/packages/agentic/desktop-test/references/lifecycle.md b/packages/agentic/desktop-test/references/lifecycle.md index f768136839..d86675c32f 100644 --- a/packages/agentic/desktop-test/references/lifecycle.md +++ b/packages/agentic/desktop-test/references/lifecycle.md @@ -122,9 +122,12 @@ 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 stays unresolved unless the -provider explicitly proves its exact process exited. Advisory exit events, -duplicate lease IDs, a helper exit, and service replacement never settle it. +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 diff --git a/packages/agentic/desktop-test/references/native-qualification.md b/packages/agentic/desktop-test/references/native-qualification.md index 6e8426f187..d70a4f7277 100644 --- a/packages/agentic/desktop-test/references/native-qualification.md +++ b/packages/agentic/desktop-test/references/native-qualification.md @@ -43,7 +43,8 @@ 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 and exact cleanup after rejected launch: +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' diff --git a/packages/agentic/desktop-test/src/applicationOwnership.contract.test.ts b/packages/agentic/desktop-test/src/applicationOwnership.contract.test.ts index b9db79c522..0989594303 100644 --- a/packages/agentic/desktop-test/src/applicationOwnership.contract.test.ts +++ b/packages/agentic/desktop-test/src/applicationOwnership.contract.test.ts @@ -172,6 +172,43 @@ nativeTest('kernel owner identity inspects the real claim as live without wall-c } }); +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, lockDirectory, reporter } = await supervisor('retained', application(), 'supervisor'); const probe = createMacOSApplicationProbe({ artifact }); diff --git a/packages/agentic/desktop-test/src/applicationOwnership.test.ts b/packages/agentic/desktop-test/src/applicationOwnership.test.ts index 34ed3caf68..b223501942 100644 --- a/packages/agentic/desktop-test/src/applicationOwnership.test.ts +++ b/packages/agentic/desktop-test/src/applicationOwnership.test.ts @@ -140,6 +140,81 @@ describe('neutral desktop application ownership', () => { 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 { diff --git a/packages/agentic/desktop-test/src/applicationOwnership.ts b/packages/agentic/desktop-test/src/applicationOwnership.ts index d67f508d31..80df1f88d0 100644 --- a/packages/agentic/desktop-test/src/applicationOwnership.ts +++ b/packages/agentic/desktop-test/src/applicationOwnership.ts @@ -128,7 +128,20 @@ export async function createDesktopApplicationHost(host: DesktopHost, options: D ...(typeof data?.processStartedAt === 'string' ? { processStartedAt: data.processStartedAt } : {}), ...(data?.launchKind === 'launch-services' || data?.launchKind === 'direct-child' ? { launchKind: data.launchKind } : {}), }); - if (data?.applicationSettlement === 'exited') entry.uncertain = false; + 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, From 12ec40a219680a4d3b81f0d5ea1de66c3221b4e6 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 12 Nov 2026 03:21:50 +0000 Subject: [PATCH 10/20] Record launch settlement review qualification Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../references/current-status.md | 23 +++++++ .../desktop-test/references/current-status.md | 63 ++++++++++++++++++- 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/packages/agentic/desktop-driver/references/current-status.md b/packages/agentic/desktop-driver/references/current-status.md index 8df7020cf1..f492f0fe03 100644 --- a/packages/agentic/desktop-driver/references/current-status.md +++ b/packages/agentic/desktop-driver/references/current-status.md @@ -516,3 +516,26 @@ 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. diff --git a/packages/agentic/desktop-test/references/current-status.md b/packages/agentic/desktop-test/references/current-status.md index 24124d720a..0447d5e6de 100644 --- a/packages/agentic/desktop-test/references/current-status.md +++ b/packages/agentic/desktop-test/references/current-status.md @@ -1083,8 +1083,10 @@ Post-run verification is retained at 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); -the pending resource correctly stayed uncertain. The corrected fixture pins -the helper's macOS 11.0 minimum in both compiler target and bundle metadata; +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`. @@ -1125,3 +1127,60 @@ 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. From 9113909896f767e3d2d02c269c0782d29562888f Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 12 Nov 2026 03:26:09 +0000 Subject: [PATCH 11/20] Record final clean-cache ownership review validation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../references/current-status.md | 10 +++++++ .../desktop-test/references/current-status.md | 27 ++++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/agentic/desktop-driver/references/current-status.md b/packages/agentic/desktop-driver/references/current-status.md index f492f0fe03..6e4858ad06 100644 --- a/packages/agentic/desktop-driver/references/current-status.md +++ b/packages/agentic/desktop-driver/references/current-status.md @@ -539,3 +539,13 @@ 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. diff --git a/packages/agentic/desktop-test/references/current-status.md b/packages/agentic/desktop-test/references/current-status.md index 0447d5e6de..ce50a14207 100644 --- a/packages/agentic/desktop-test/references/current-status.md +++ b/packages/agentic/desktop-test/references/current-status.md @@ -1103,7 +1103,7 @@ 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. -### Final repository validation +### Pre-review repository validation At source `48717724f0df1e8f89d76b66c48d37fac93e871b`, `yarn clean-lage-cache` passed once, followed by the locked aggregate: @@ -1184,3 +1184,28 @@ 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. From 6690c6d68ebdc7b79db175ac77535dc296c85f50 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 12 Nov 2026 04:15:58 +0000 Subject: [PATCH 12/20] Separate macOS application identity inspection from control Observe the authenticated Storybook bridge incarnation through the session helper without taking another controlling lease or disturbing its window records. Preserve foreign-service exclusion and the existing Windows identity attachment path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../neutral-macos-application-ownership.md | 5 + .../agentic/desktop-driver/native/PROTOCOL.md | 11 ++ .../DesktopDriverHost/Application.swift | 44 +++++- .../ApplicationIncarnationSelfTest.swift | 20 +++ .../Sources/DesktopDriverHost/Driver.swift | 2 + .../Sources/DesktopDriverHost/Host.swift | 1 + .../desktop-driver/native/protocol.json | 6 + .../desktop-driver/references/contributing.md | 4 + ...iveDesktopHost.applicationIdentity.test.ts | 138 ++++++++++++++++ .../src/hosts/native/NativeDesktopHost.ts | 24 +++ packages/agentic/desktop-driver/src/index.ts | 1 + .../desktop-driver/src/native/index.ts | 1 + .../desktop-driver/src/native/types.ts | 5 +- .../src/applicationOwnership.contract.test.ts | 23 ++- .../storybook-desktop/config/server.cjs | 30 ++-- .../references/contributing.md | 11 +- .../src/driver/applicationIdentity.test.ts | 148 ++++++++++++++++++ .../src/driver/applicationIdentity.ts | 25 +++ .../storybook-desktop/src/driver/index.ts | 1 + 19 files changed, 468 insertions(+), 32 deletions(-) create mode 100644 packages/agentic/desktop-driver/src/hosts/native/NativeDesktopHost.applicationIdentity.test.ts create mode 100644 packages/agentic/storybook-desktop/src/driver/applicationIdentity.test.ts create mode 100644 packages/agentic/storybook-desktop/src/driver/applicationIdentity.ts diff --git a/.changeset/neutral-macos-application-ownership.md b/.changeset/neutral-macos-application-ownership.md index dcda6b7204..0093376096 100644 --- a/.changeset/neutral-macos-application-ownership.md +++ b/.changeset/neutral-macos-application-ownership.md @@ -17,3 +17,8 @@ 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. diff --git a/packages/agentic/desktop-driver/native/PROTOCOL.md b/packages/agentic/desktop-driver/native/PROTOCOL.md index 4b8656cce8..b3521e364f 100644 --- a/packages/agentic/desktop-driver/native/PROTOCOL.md +++ b/packages/agentic/desktop-driver/native/PROTOCOL.md @@ -144,6 +144,17 @@ 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 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 968cbd4590..c842a4894d 100644 --- a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Application.swift +++ b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Application.swift @@ -501,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" @@ -531,8 +557,7 @@ final class ApplicationManager { expectedExecutablePath: configuredExecutablePath, code: ErrorCode.leaseInvalid) _ = try waitForUniqueWindow(processID: pid, exactTitle: windowTitle, timeoutMilliseconds: 10_000, token: token, code: ErrorCode.attachFailed) - return try createLease(ownership: "attached", running: running, windowTitle: windowTitle, params: params, - expectedStartedAt: expected) + return Attachment(running: running, windowTitle: windowTitle, startedAt: expected) } if let leasePath = optionalString(application, "leasePath") { @@ -562,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 { @@ -615,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 { @@ -638,12 +664,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( @@ -677,7 +704,7 @@ final class ApplicationManager { continue } if candidateTitle == windowTitle { - matches.append((candidate, window)) + matches.append((candidate, startedAt)) } } } @@ -777,7 +804,10 @@ 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) } /// Releases a lease. A launched application is proven exited first, so a process that will not die keeps its lease 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 e79f77cdf6..e58c85fdd0 100644 --- a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/ApplicationIncarnationSelfTest.swift +++ b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/ApplicationIncarnationSelfTest.swift @@ -53,6 +53,26 @@ extension SelfTestRunner { 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() { 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 de9f25b7ab..ff8bf04b99 100644 --- a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Driver.swift +++ b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Driver.swift @@ -108,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 9555c787d9..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", diff --git a/packages/agentic/desktop-driver/native/protocol.json b/packages/agentic/desktop-driver/native/protocol.json index c806994e0b..8bb80c8cdd 100644 --- a/packages/agentic/desktop-driver/native/protocol.json +++ b/packages/agentic/desktop-driver/native/protocol.json @@ -23,6 +23,12 @@ } } }, + "identityInspection": { + "command": "inspectApplication", + "result": { "$ref": "#/macosApplicationOwnership/processIdentity" }, + "acquiresLease": false, + "registersWindows": false + }, "launchKind": ["launch-services", "direct-child"], "containment": ["uncontained", "external"], "applicationSettlement": ["not-started", "exited", "unknown"], 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/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/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 b6cf48f0ab..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; @@ -63,7 +66,7 @@ export type NativeDesktopApplicationDescriptor = { leaseNonce?: string; leasePath?: string; /** Exact macOS attach identity. Never grants launch/termination authority and cannot be combined with a legacy lease. */ - processIdentity?: Readonly<{ processId: number; processStartedAt: string }>; + processIdentity?: NativeApplicationIdentity; windowTitle?: string; workingDirectory?: string; }; diff --git a/packages/agentic/desktop-test/src/applicationOwnership.contract.test.ts b/packages/agentic/desktop-test/src/applicationOwnership.contract.test.ts index 0989594303..c0454535c5 100644 --- a/packages/agentic/desktop-test/src/applicationOwnership.contract.test.ts +++ b/packages/agentic/desktop-test/src/applicationOwnership.contract.test.ts @@ -137,16 +137,17 @@ async function supervisor(name: string, descriptor: NativeDesktopApplicationDesc 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 () => nativeHost(descriptor), + hostFactory: async () => host, recovery: { root: path.join(directory, `${name}-recovery`), probeApplication: createMacOSApplicationProbe({ artifact }) }, }); - return { owner, lockDirectory, reporter }; + return { owner, host, lockDirectory, reporter }; } nativeTest('kernel owner identity inspects the real claim as live without wall-clock conversion', async () => { @@ -210,7 +211,7 @@ nativeTest('failed native pre-spawn and rejected LaunchServices launches release }); nativeTest('supervisor launch survives two real sessions, records native owner/application identity, and closes exactly', async () => { - const { owner, lockDirectory, reporter } = await supervisor('retained', application(), 'supervisor'); + const { owner, host, lockDirectory, reporter } = await supervisor('retained', application(), 'supervisor'); const probe = createMacOSApplicationProbe({ artifact }); let lease: ApplicationLease | undefined; try { @@ -222,6 +223,12 @@ nativeTest('supervisor launch survives two real sessions, records native owner/a 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); @@ -292,7 +299,17 @@ nativeTest('independent services exclude the exact external application and pres 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(); 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'; From 49f58b424924c96f6b4e19268394dd60d194a9a8 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 12 Nov 2026 04:23:00 +0000 Subject: [PATCH 13/20] Record macOS identity observation regression qualification Trace both Storybook CI failures to competing identity attachment, record the default-TMPDIR focused regressions and full package checks, and preserve the positive native qualification gates. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../references/current-status.md | 42 +++++++++++ .../desktop-test/references/current-status.md | 72 +++++++++++++++++++ 2 files changed, 114 insertions(+) diff --git a/packages/agentic/desktop-driver/references/current-status.md b/packages/agentic/desktop-driver/references/current-status.md index 6e4858ad06..2ce2d768c5 100644 --- a/packages/agentic/desktop-driver/references/current-status.md +++ b/packages/agentic/desktop-driver/references/current-status.md @@ -549,3 +549,45 @@ 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. diff --git a/packages/agentic/desktop-test/references/current-status.md b/packages/agentic/desktop-test/references/current-status.md index ce50a14207..0ac5a08838 100644 --- a/packages/agentic/desktop-test/references/current-status.md +++ b/packages/agentic/desktop-test/references/current-status.md @@ -1209,3 +1209,75 @@ test waiver was needed. 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. From 3ff7c5cb18e4bff454d066187c1045093ddabe9e Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 12 Nov 2026 04:36:10 +0000 Subject: [PATCH 14/20] Record final identity observation regression validation Preserve the unexplained first aggregate failure, the unchanged isolated Storybook pass, and the successful serialized clean-cache aggregate without treating portable results as positive native qualification. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../references/current-status.md | 14 ++++++++ .../desktop-test/references/current-status.md | 35 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/packages/agentic/desktop-driver/references/current-status.md b/packages/agentic/desktop-driver/references/current-status.md index 2ce2d768c5..8553d15c56 100644 --- a/packages/agentic/desktop-driver/references/current-status.md +++ b/packages/agentic/desktop-driver/references/current-status.md @@ -591,3 +591,17 @@ 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. diff --git a/packages/agentic/desktop-test/references/current-status.md b/packages/agentic/desktop-test/references/current-status.md index 0ac5a08838..d31c141fb8 100644 --- a/packages/agentic/desktop-test/references/current-status.md +++ b/packages/agentic/desktop-test/references/current-status.md @@ -1281,3 +1281,38 @@ Logs and curated CI analysis are under `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. From d58d473b7bbef26619db596f420fa837687badf3 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 12 Nov 2026 11:50:17 +0000 Subject: [PATCH 15/20] Fix live macOS discovery and record ownership qualification Enumerate current-user PIDs for warmed descriptor discovery and refuse unavailable enumeration. Cover newly launched and duplicate bundle identities with a native regression. Record signed AppKit ownership/input and separate Fabric/Paper smoke evidence without closing the remaining causal-acquisition and survivor gates. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../neutral-macos-application-ownership.md | 4 + .../desktop-driver/native/macos/README.md | 12 +- .../DesktopDriverHost/Application.swift | 16 +- .../DesktopDriverHost/ServiceIdentity.swift | 2 +- .../references/current-status.md | 157 ++++++++- .../desktop-test/references/current-status.md | 333 +++++++++++++++++- .../src/applicationOwnership.contract.test.ts | 67 ++++ 7 files changed, 569 insertions(+), 22 deletions(-) diff --git a/.changeset/neutral-macos-application-ownership.md b/.changeset/neutral-macos-application-ownership.md index 0093376096..842440bc7a 100644 --- a/.changeset/neutral-macos-application-ownership.md +++ b/.changeset/neutral-macos-application-ownership.md @@ -22,3 +22,7 @@ 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. diff --git a/packages/agentic/desktop-driver/native/macos/README.md b/packages/agentic/desktop-driver/native/macos/README.md index cf10b35c1e..afc88adc5e 100644 --- a/packages/agentic/desktop-driver/native/macos/README.md +++ b/packages/agentic/desktop-driver/native/macos/README.md @@ -223,12 +223,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 c842a4894d..d89b829c4c 100644 --- a/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Application.swift +++ b/packages/agentic/desktop-driver/native/macos/Sources/DesktopDriverHost/Application.swift @@ -650,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 } @@ -810,6 +808,18 @@ final class ApplicationManager { 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 /// held and the caller can retry or report it. An attached application is left running. Returns the ids of the windows /// the lease tracked, which are forgotten only once the lease is gone. 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.. 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. diff --git a/packages/agentic/desktop-test/src/applicationOwnership.contract.test.ts b/packages/agentic/desktop-test/src/applicationOwnership.contract.test.ts index c0454535c5..32a4bb8839 100644 --- a/packages/agentic/desktop-test/src/applicationOwnership.contract.test.ts +++ b/packages/agentic/desktop-test/src/applicationOwnership.contract.test.ts @@ -266,6 +266,73 @@ nativeTest('supervisor launch survives two real sessions, records native owner/a }); }); +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; From d5a7530a9f2d4cb78721a36c1109b5c36fbdc5a1 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 12 Nov 2026 22:41:22 +0000 Subject: [PATCH 16/20] Qualify interrupted macOS ownership publication and survivors Exercise real AppKit acquisition publication failure and abrupt exact-owner death before and after acquired-record publication. Preserve fenced claims while native apps and known uncontained descendants survive, then verify exact operator cleanup. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../neutral-macos-application-ownership.md | 5 + .../desktop-driver/native/macos/README.md | 7 + .../macos/fixtures/ApplicationFixture.swift | 21 ++ .../application-ownership/crash-owner.mjs | 36 ++++ .../references/native-qualification.md | 16 +- .../src/applicationOwnership.contract.test.ts | 191 ++++++++++++++++++ 6 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 packages/agentic/desktop-test/fixtures/application-ownership/crash-owner.mjs diff --git a/.changeset/neutral-macos-application-ownership.md b/.changeset/neutral-macos-application-ownership.md index 842440bc7a..793b664a95 100644 --- a/.changeset/neutral-macos-application-ownership.md +++ b/.changeset/neutral-macos-application-ownership.md @@ -26,3 +26,8 @@ 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. diff --git a/packages/agentic/desktop-driver/native/macos/README.md b/packages/agentic/desktop-driver/native/macos/README.md index afc88adc5e..c6c80525bf 100644 --- a/packages/agentic/desktop-driver/native/macos/README.md +++ b/packages/agentic/desktop-driver/native/macos/README.md @@ -106,6 +106,13 @@ 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. +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. + ## Signing ### Stable development signing diff --git a/packages/agentic/desktop-driver/native/macos/fixtures/ApplicationFixture.swift b/packages/agentic/desktop-driver/native/macos/fixtures/ApplicationFixture.swift index ebfe74d83a..d01c376788 100644 --- a/packages/agentic/desktop-driver/native/macos/fixtures/ApplicationFixture.swift +++ b/packages/agentic/desktop-driver/native/macos/fixtures/ApplicationFixture.swift @@ -5,6 +5,7 @@ final class ApplicationFixture: NSObject, NSApplicationDelegate { private var window: NSWindow? private var count = 0 private let value = NSTextField(labelWithString: "Count: 0") + private var descendant: Process? func applicationDidFinishLaunching(_ notification: Notification) { let arguments = ProcessInfo.processInfo.arguments @@ -29,6 +30,26 @@ final class ApplicationFixture: NSObject, NSApplicationDelegate { self.window = window window.makeKeyAndOrderFront(nil) NSApplication.shared.activate(ignoringOtherApps: true) + if let childIndex = arguments.firstIndex(of: "--descendant-ready-file").map({ $0 + 1 }), + arguments.indices.contains(childIndex) + { + let child = Process() + child.executableURL = URL(fileURLWithPath: "/bin/sleep") + child.arguments = ["300"] + do { + try child.run() + descendant = child + let record = [ + "processId": Int(child.processIdentifier), + "parentProcessId": Int(ProcessInfo.processInfo.processIdentifier), + ] + let data = try JSONSerialization.data(withJSONObject: record) + try data.write(to: URL(fileURLWithPath: arguments[childIndex]), options: .atomic) + } catch { + NSApplication.shared.terminate(nil) + return + } + } if let readyIndex = arguments.firstIndex(of: "--ready-file").map({ $0 + 1 }), arguments.indices.contains(readyIndex) { 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..a7c73269d9 --- /dev/null +++ b/packages/agentic/desktop-test/fixtures/application-ownership/crash-owner.mjs @@ -0,0 +1,36 @@ +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'; + +const [configurationFile, phase] = process.argv.slice(2); +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. + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0); + } + }, +}); diff --git a/packages/agentic/desktop-test/references/native-qualification.md b/packages/agentic/desktop-test/references/native-qualification.md index d70a4f7277..2f66637aec 100644 --- a/packages/agentic/desktop-test/references/native-qualification.md +++ b/packages/agentic/desktop-test/references/native-qualification.md @@ -37,7 +37,18 @@ 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. Its evidence is written to the desktop-test package's +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. + +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. @@ -65,6 +76,9 @@ 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 diff --git a/packages/agentic/desktop-test/src/applicationOwnership.contract.test.ts b/packages/agentic/desktop-test/src/applicationOwnership.contract.test.ts index 32a4bb8839..987f0ab327 100644 --- a/packages/agentic/desktop-test/src/applicationOwnership.contract.test.ts +++ b/packages/agentic/desktop-test/src/applicationOwnership.contract.test.ts @@ -465,6 +465,197 @@ nativeTest('bare executable launches are direct children, not LaunchServices own 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[] = []; From ca88c289c0fbd79b03dbd43b9bd85f0b21e7e800 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 12 Nov 2026 22:47:39 +0000 Subject: [PATCH 17/20] Record rebased macOS ownership failure qualification Preserve the independent F6 provenance evidence and record signed AppKit interrupted-publication and survivor results, exact cleanup, full package checks, and remaining qualification limits. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../references/current-status.md | 39 +++++++ .../desktop-test/references/current-status.md | 101 ++++++++++++++++-- 2 files changed, 132 insertions(+), 8 deletions(-) diff --git a/packages/agentic/desktop-driver/references/current-status.md b/packages/agentic/desktop-driver/references/current-status.md index 07d17faa2f..650d45b465 100644 --- a/packages/agentic/desktop-driver/references/current-status.md +++ b/packages/agentic/desktop-driver/references/current-status.md @@ -744,3 +744,42 @@ Documentation closeout passed all four changed Markdown link checks (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`. diff --git a/packages/agentic/desktop-test/references/current-status.md b/packages/agentic/desktop-test/references/current-status.md index 5a56b99227..35f74a1a48 100644 --- a/packages/agentic/desktop-test/references/current-status.md +++ b/packages/agentic/desktop-test/references/current-status.md @@ -64,14 +64,14 @@ did not retain a portable raw-report locator; do not imply otherwise. ## Full-submission checklist -| Area | Remaining evidence/work | -| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| macOS | Signed AppKit and separate Fabric/Paper positive owned/external lifecycle, read-only inspection, exclusion and exact primary cleanup passed on 2026-11-12. Still required: supervisor death with Metro/channel/helper survivors, arbitrary descendants, interrupted native publication, live failed-cleanup/service-restart cases and repeated-run reliability. | -| 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. | +| 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); @@ -1643,3 +1643,88 @@ Logs in the qualification artifact root: `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. From 16558610ff2bd14775430f86c738c12947fe81ee Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 12 Nov 2026 23:08:01 +0000 Subject: [PATCH 18/20] Record rebased Fabric and Paper ownership qualification Record both 224-case consumer smokes and exact cleanup, while retaining the failed plain aggregate evidence and known boundedCommand startup assertion. No concurrency/environment workaround or unrelated fix was used. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../references/current-status.md | 27 +++++ .../desktop-test/references/current-status.md | 103 ++++++++++++++++++ 2 files changed, 130 insertions(+) diff --git a/packages/agentic/desktop-driver/references/current-status.md b/packages/agentic/desktop-driver/references/current-status.md index 650d45b465..7f59d3e31b 100644 --- a/packages/agentic/desktop-driver/references/current-status.md +++ b/packages/agentic/desktop-driver/references/current-status.md @@ -783,3 +783,30 @@ and aggregate outcomes are recorded in 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. diff --git a/packages/agentic/desktop-test/references/current-status.md b/packages/agentic/desktop-test/references/current-status.md index 35f74a1a48..e89cf5e19d 100644 --- a/packages/agentic/desktop-test/references/current-status.md +++ b/packages/agentic/desktop-test/references/current-status.md @@ -1728,3 +1728,106 @@ 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. From 0fb6cd47fccc4f6210797b25085e7b56aa8ca4fe Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 12 Nov 2026 23:20:50 +0000 Subject: [PATCH 19/20] Bound abandoned macOS ownership qualification fixtures Retire the interrupted-publication owner nonzero after two minutes and request AppKit fixture shutdown after five minutes. Exercise the same wait in the existing portable ownership suite without acquiring native resources; preserve exact-identity cleanup and durable workload fences. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/neutral-macos-application-ownership.md | 2 ++ .../agentic/desktop-driver/native/macos/README.md | 6 +++++- .../native/macos/fixtures/ApplicationFixture.swift | 4 ++++ .../fixtures/application-ownership/crash-owner.mjs | 9 ++++++++- .../desktop-test/references/native-qualification.md | 8 ++++++++ .../desktop-test/src/applicationOwnership.test.ts | 13 +++++++++++++ 6 files changed, 40 insertions(+), 2 deletions(-) diff --git a/.changeset/neutral-macos-application-ownership.md b/.changeset/neutral-macos-application-ownership.md index 793b664a95..c0e3bf2da0 100644 --- a/.changeset/neutral-macos-application-ownership.md +++ b/.changeset/neutral-macos-application-ownership.md @@ -31,3 +31,5 @@ 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/macos/README.md b/packages/agentic/desktop-driver/native/macos/README.md index c6c80525bf..ef6f3be47c 100644 --- a/packages/agentic/desktop-driver/native/macos/README.md +++ b/packages/agentic/desktop-driver/native/macos/README.md @@ -104,7 +104,8 @@ 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. +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 @@ -112,6 +113,9 @@ 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 diff --git a/packages/agentic/desktop-driver/native/macos/fixtures/ApplicationFixture.swift b/packages/agentic/desktop-driver/native/macos/fixtures/ApplicationFixture.swift index d01c376788..4d5f0d2ea1 100644 --- a/packages/agentic/desktop-driver/native/macos/fixtures/ApplicationFixture.swift +++ b/packages/agentic/desktop-driver/native/macos/fixtures/ApplicationFixture.swift @@ -8,6 +8,10 @@ final class ApplicationFixture: NSObject, NSApplicationDelegate { private var descendant: Process? func applicationDidFinishLaunching(_ notification: Notification) { + // A crashed test owner must not leave this uncontained fixture running indefinitely. + DispatchQueue.main.asyncAfter(deadline: .now() + 300) { + NSApplication.shared.terminate(nil) + } let arguments = ProcessInfo.processInfo.arguments let titleIndex = arguments.firstIndex(of: "--title").map { $0 + 1 } let title = titleIndex.flatMap { arguments.indices.contains($0) ? arguments[$0] : nil } diff --git a/packages/agentic/desktop-test/fixtures/application-ownership/crash-owner.mjs b/packages/agentic/desktop-test/fixtures/application-ownership/crash-owner.mjs index a7c73269d9..f693969308 100644 --- a/packages/agentic/desktop-test/fixtures/application-ownership/crash-owner.mjs +++ b/packages/agentic/desktop-test/fixtures/application-ownership/crash-owner.mjs @@ -5,7 +5,14 @@ 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 }); @@ -30,7 +37,7 @@ await createDesktopApplicationHost(native, { mode: 0o600, }); // The parent kills this exact owner while publication or startup is interrupted. - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0); + waitForRetirement(120_000); } }, }); diff --git a/packages/agentic/desktop-test/references/native-qualification.md b/packages/agentic/desktop-test/references/native-qualification.md index 2f66637aec..640aa1250a 100644 --- a/packages/agentic/desktop-test/references/native-qualification.md +++ b/packages/agentic/desktop-test/references/native-qualification.md @@ -48,6 +48,14 @@ 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 diff --git a/packages/agentic/desktop-test/src/applicationOwnership.test.ts b/packages/agentic/desktop-test/src/applicationOwnership.test.ts index b223501942..6abb06f3d5 100644 --- a/packages/agentic/desktop-test/src/applicationOwnership.test.ts +++ b/packages/agentic/desktop-test/src/applicationOwnership.test.ts @@ -1,3 +1,4 @@ +import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; @@ -49,6 +50,18 @@ function fixture() { } 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 () => { From 507effebd8568e0ede8ffe651a5cade0b1b25168 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 12 Nov 2026 23:26:24 +0000 Subject: [PATCH 20/20] Record bounded fixture review-fix qualification and remaining gates Retain portable red/green, signed compile, locked-console native limitation, single plain aggregate failure, and exact-worktree cleanup evidence without carrying pre-fix runtime results forward. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../references/current-status.md | 33 +++++++ .../desktop-test/references/current-status.md | 91 +++++++++++++++++++ 2 files changed, 124 insertions(+) diff --git a/packages/agentic/desktop-driver/references/current-status.md b/packages/agentic/desktop-driver/references/current-status.md index 7f59d3e31b..b50d02b79f 100644 --- a/packages/agentic/desktop-driver/references/current-status.md +++ b/packages/agentic/desktop-driver/references/current-status.md @@ -810,3 +810,36 @@ 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/`. diff --git a/packages/agentic/desktop-test/references/current-status.md b/packages/agentic/desktop-test/references/current-status.md index e89cf5e19d..d5af5550ea 100644 --- a/packages/agentic/desktop-test/references/current-status.md +++ b/packages/agentic/desktop-test/references/current-status.md @@ -1831,3 +1831,94 @@ absence are retained in `cleanup-first.json`, `cleanup-first.log` and 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`.