diff --git a/.changeset/windows-exact-application-ownership.md b/.changeset/windows-exact-application-ownership.md new file mode 100644 index 0000000000..8fa664d8e4 --- /dev/null +++ b/.changeset/windows-exact-application-ownership.md @@ -0,0 +1,7 @@ +--- +'@fluentui-react-native/desktop-driver': patch +'@fluentui-react-native/desktop-test': patch +'@fluentui-react-native/storybook-desktop': patch +--- + +Add exact Windows and Win32 application identities, read-only inspection, cross-service application control exclusion, and bounded recovery of acquired Windows application resources from verified crashed owners. Retire reused recorded incarnations without signalling their replacements, and resume interrupted recovery barriers only after verifying their native owners exited. Keep packaged activation explicitly uncontained and uncertain launches fenced. Resolve Storybook bridge identities without acquiring another controlling attachment. diff --git a/packages/agentic/desktop-driver/README.md b/packages/agentic/desktop-driver/README.md index 2f5fe525ce..d8c8d74895 100644 --- a/packages/agentic/desktop-driver/README.md +++ b/packages/agentic/desktop-driver/README.md @@ -33,10 +33,11 @@ a new verified selection; finding fresh elements alone cannot restore readiness. | React Native Windows Fabric | Windows 11 | `x64` | C++20, UI Automation, SendInput, Windows Graphics Capture | | React Native Win32 Paper | Windows 11 | `x64` | Same Windows helper and protocol | -V1 permits one active WebDriver session per registered target in one service; -cross-service application exclusion remains work in progress. Native builds run -only on the target operating system. Installation never compiles or downloads a -helper. +V1 permits one active WebDriver session per registered target in one service. +Native helpers also exclude controlling the same exact application incarnation +across services; endpoint qualification and recovery limits are recorded in +[current status](references/current-status.md). Native builds run only on the +target operating system. Installation never compiles or downloads a helper. ## Start here diff --git a/packages/agentic/desktop-driver/native/PROTOCOL.md b/packages/agentic/desktop-driver/native/PROTOCOL.md index b3521e364f..3759c4f6a4 100644 --- a/packages/agentic/desktop-driver/native/PROTOCOL.md +++ b/packages/agentic/desktop-driver/native/PROTOCOL.md @@ -144,8 +144,8 @@ 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 +The `inspectApplication` command accepts the same configured target parameters +as `attach`. On macOS it 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, @@ -153,8 +153,8 @@ 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. +unverifiable identity/Accessibility fail closed. Windows implements the same +read-only boundary with its additional native identity evidence described below. 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 @@ -179,6 +179,79 @@ uses the same exact-incarnation cleanup path. These additive fields are described in [protocol.json](protocol.json) and retain wire version 1.0. +### Windows application ownership + +Windows and Win32 leases and inspection results retain the canonical absolute +`executablePath`, `interactiveSession: "windows-session:"`, and the native +process creation timestamp with exactly seven fractional digits (100 ns), plus +`aumid` when Windows reports packaged identity. `APPMODEL_ERROR_NO_APPLICATION` +means an unpackaged process; an unreadable AUMID is unknown evidence, not proof +that a process is unpackaged. The process must belong to the helper's user and +interactive session. For REX, the canonical `ReactTest.exe` image, PID, native +creation time, and session identify one host incarnation, not just its title. + +An exact external attach may supply `processIdentity` with all four required +fields (`processId`, `processStartedAt`, `executablePath`, `interactiveSession`) +and the captured `aumid` when packaged. Optional descriptor executable/AUMID +constraints must also match. Native start comparison is exact: even one 100 ns +tick differs. This identity cannot authorize a launch or be combined with a +legacy nonce lease. The legacy lease parser retains its existing timestamp +compatibility; it is not the exact-pin API. `inspectApplication` checks the same +uniqueness and descriptor constraints without creating a lease, registering +windows, or acquiring application/input control. + +Independent helpers exclude control of an exact PID/start incarnation with a +session-local, nonwaiting named semaphore. A successful launch holds control of +its native launch root before discovery and, if different, of the primary +process before publishing the lease. Multiple leases within one helper share +its control; another helper receives `application-lock-busy`. Closing an +attached lease releases control without signalling the application. This lock +is separate from the physical-input mutex. Failed contenders retain no waiter +handle; after a crash, Windows destroys the semaphore when its last handle +closes. + +Unpackaged leases report `launchKind: "direct-child"` and containment +`job-object`; packaged activations report `launchKind: "packaged-activation"` +and remain explicitly `uncontained`. Assigning a job after activation cannot +close the already-running process's containment race, and existing job +restrictions may forbid assignment. The helper does not promise packaged or +arbitrary-descendant containment. Attached applications remain `external` and +are never assigned to owned jobs. + +Failed or cancelled Windows launches use the same `applicationSettlement` +verdicts and metadata locations as macOS. A trusted captured process is named +with its full identity, causal launch kind and containment when available. +`not-started` proves rejection before creation or refusal to adopt a +pre-existing activation. `exited` requires confirmed cleanup; unreadable or +surviving identities remain `unknown`. A pending or unleased acquisition is not +automatic recovery authority, even when a primary process was captured. + +The trusted `recoverApplication` command accepts `{ endpoint, identity }`. +Only the workload owner's recovery path calls it; no WebDriver capability or +route exposes it. It first opens `PROCESS_QUERY_LIMITED_INFORMATION` and reads +native creation time. A different creation time proves the recorded incarnation +exited: it returns `{ state: "exited" }` without requesting termination access, +acquiring application control, or signalling the replacement. An absent PID +also returns `exited`. Access denied or unreadable creation time remains +`application-identity-unknown` and fenced, never guessed exit. + +For a matching creation time, the query handle stays open to prevent PID reuse +while acquiring cleanup access. Recovery then verifies executable, AUMID, user +and session, acquires application control, closes/terminates only that recorded +process, and proves exit before returning `exited`. A matching start with other +identity mismatches, another controller, cancellation, or unconfirmed exit +remains fenced. Attach/inspection still reject reused PIDs; only trusted +recovery retires the absent recorded incarnation. +Mismatch reasons include `identity-start-mismatch` (attach/inspection), `identity-session-mismatch`, +`identity-executable-mismatch`, and `identity-aumid-mismatch`; ambiguous +process/window candidates report `application-identity-ambiguous`, and +unreadable candidates report `application-identity-unknown`. + +These additive Windows schemas are in [protocol.json](protocol.json) and retain +FDR1 version 1.0. + +### Snapshot metadata + 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 diff --git a/packages/agentic/desktop-driver/native/protocol.json b/packages/agentic/desktop-driver/native/protocol.json index 8bb80c8cdd..bc05ac5891 100644 --- a/packages/agentic/desktop-driver/native/protocol.json +++ b/packages/agentic/desktop-driver/native/protocol.json @@ -10,6 +10,64 @@ "jsonType": 1, "binaryType": 2 }, + "windowsApplicationOwnership": { + "processIdentity": { + "type": "object", + "additionalProperties": false, + "required": ["processId", "processStartedAt", "executablePath", "interactiveSession"], + "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]{7}Z$" + }, + "executablePath": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "interactiveSession": { "type": "string", "pattern": "^windows-session:[0-9]{1,10}$" }, + "aumid": { "type": "string", "minLength": 1, "maxLength": 4096 } + } + }, + "identityInspection": { + "command": "inspectApplication", + "result": { "$ref": "#/windowsApplicationOwnership/processIdentity" }, + "acquiresLease": false, + "registersWindows": false + }, + "survivorRecovery": { + "command": "recoverApplication", + "identity": { "$ref": "#/windowsApplicationOwnership/processIdentity" }, + "result": { + "type": "object", + "additionalProperties": false, + "required": ["state"], + "properties": { + "state": { + "const": "exited", + "description": "The recorded incarnation exited, including a reused PID whose live replacement is never signalled." + } + } + }, + "trustedWorkloadOwnerOnly": true, + "creationTimeQuery": "PROCESS_QUERY_LIMITED_INFORMATION", + "pidReuseSettlement": "exited-without-signalling-current-process", + "unreadableCreationTime": "fail-closed", + "holdsApplicationControlLockForCleanup": true + }, + "applicationControl": "session-local-nonwaiting-semaphore-pid-and-native-start", + "launchKind": ["direct-child", "packaged-activation"], + "containment": ["job-object", "uncontained", "external"], + "applicationSettlement": ["not-started", "exited", "unknown"], + "failureMetadataFields": [ + "applicationSettlement", + "processId", + "processStartedAt", + "executablePath", + "interactiveSession", + "aumid", + "launchKind", + "containment" + ], + "failureMetadataLocations": ["response.error.data", "cancelled.data"] + }, "macosApplicationOwnership": { "processIdentity": { "type": "object", diff --git a/packages/agentic/desktop-driver/native/windows/README.md b/packages/agentic/desktop-driver/native/windows/README.md index 148c8de0dd..35f6604a3a 100644 --- a/packages/agentic/desktop-driver/native/windows/README.md +++ b/packages/agentic/desktop-driver/native/windows/README.md @@ -86,17 +86,47 @@ Accessibility help text uses cached `UIA_HelpTextPropertyId` with defaults disabled. An explicit empty string is supported; absent or non-string values remain unsupported. -Attach accepts an owner-generated nonce-bound lease, or an exact title -qualified by the canonical executable or AUMID. Title-only attach, zero +Attach accepts an owner-generated nonce-bound lease, an exact native +`processIdentity`, or an exact title qualified by the canonical executable or +AUMID. The exact pin requires PID, seven-fractional-digit native creation time, +canonical executable, session, and AUMID when packaged; it rejects a one-tick +start mismatch instead of applying the legacy lease parser's tolerance. +Title-only attach, zero matches, and multiple qualified matches fail. Attach fails closed: a lease's process must be in the helper's session and match the lease's executable or AUMID, and an unleased attach fails as ambiguous when any titled candidate's identity cannot be read rather than skipping it. -Leases record ownership, PID, process start, executable, the primary window, -and `containment`. Attached apps survive WebDriver deletion and helper +Leases record ownership, PID, native process start, canonical executable, +`interactiveSession`, packaged AUMID when present, the primary window, +`launchKind`, and `containment`. The executable/PID/start/session tuple also +identifies an exact REX host incarnation. Unknown native evidence and multiple +matching processes/windows fail closed with fixed diagnostic reasons. +Attached apps survive WebDriver deletion and helper disposal (`external`); launched apps are closed only by their exact recorded process identity. +`inspectApplication` uses the same descriptor and uniqueness checks without +creating leases, application/input locks, or window records. Independent +helpers can observe a controlled application but cannot both control its exact +incarnation: a session-local nonwaiting semaphore is keyed by PID and native +creation time, and remains held through lease cleanup. A launch acquires root +control before cleanup authority or window discovery, so a losing concurrent +packaged activation cannot terminate another service's winning incarnation. +The separate input mutex still serializes +physical input across different applications and helpers. + +The trusted `recoverApplication` operation reads creation time first through a +`PROCESS_QUERY_LIMITED_INFORMATION` handle. A different start proves the +recorded incarnation exited and returns `exited` without termination access, +application control, or signals to its live replacement. An unreadable start, +including access denied, remains fenced. For a matching start, the query handle +prevents PID reuse while recovery acquires cleanup access, verifies executable, +AUMID, user and session, acquires application control, and proves exact exit. +Other identity mismatches, unknown evidence, another controller, or unconfirmed +cleanup are refused without adopting a replacement process. No WebDriver route exposes +this operation. Its caller must have an acquired, owned application resource, +not an external attachment or an incomplete launch. + Unpackaged arguments use the standard `CommandLineToArgvW` quoting rules. Launch ownership requires a non-preexisting window from the exact created process or a verified descendant, plus a matching canonical executable or @@ -134,6 +164,20 @@ or ends it. A lease is created only when the leased process's start time can be read, because that start time is what later tells the same process from a reused process id. The helper never places an attached application in a job. +Packaged activation deliberately remains `uncontained`. Assigning a job after +activation leaves a pre-assignment execution race and may fail under existing +job restrictions, so this backend does not advertise reliable packaged +containment. The workload claim records the exact acquired application before +session use and remains fenced after failed cleanup. A later Windows native +supervisor may reconcile that application only after proving the recorded +owner exited or its PID was reused; unknown resources/effects and unleased +launches still require operator reconciliation. Exact primary-process cleanup +does not prove arbitrary uncontained descendants exited. + +Failed/cancelled launches carry `applicationSettlement` and captured native +identity in `response.error.data` or `cancelled.data`; the +[protocol](../PROTOCOL.md#windows-application-ownership) defines the allowlist. + ## Automation behavior The helper provides: diff --git a/packages/agentic/desktop-driver/native/windows/src/application.cpp b/packages/agentic/desktop-driver/native/windows/src/application.cpp index 6d6baa0082..07cdd010e8 100644 --- a/packages/agentic/desktop-driver/native/windows/src/application.cpp +++ b/packages/agentic/desktop-driver/native/windows/src/application.cpp @@ -17,6 +17,7 @@ #include "geometry.h" #include "settlement.h" +#include "winsecurity.h" namespace furn { @@ -222,6 +223,131 @@ std::wstring ReadProcessApplicationUserModelId(HANDLE process) { return value; } +json::Value ReadExactApplicationIdentity(DWORD processId, HANDLE process) { + if (!IsProcessAlive(process)) { + throw HelperError(kErrorAttachFailed, "The application process has exited.", "application-exited"); + } + RequireProcessInHelperSession(processId, kErrorAttachFailed); + HANDLE rawToken = nullptr; + if (OpenProcessToken(process, TOKEN_QUERY, &rawToken) == FALSE) { + throw HelperError(kErrorAttachFailed, "The application owner cannot be verified.", "application-identity-unknown"); + } + const ScopedHandle token(rawToken); + const Sid owner = TokenUserSid(token.Get()); + const Sid self = CurrentUserSid(); + if (!owner.Valid() || !self.Valid() || !self.Equals(owner.Get())) { + throw HelperError(kErrorAttachFailed, "The application does not belong to the current user.", "identity-user-mismatch"); + } + FILETIME started{}; + DWORD session = 0; + const std::wstring image = NormalizeExecutablePath(ReadProcessImagePath(process)); + if (!TryReadProcessStartTime(process, started) || FileTimeTicks(started) == 0 || image.empty() || + ProcessIdToSessionId(processId, &session) == FALSE) { + throw HelperError(kErrorAttachFailed, "The application's exact native identity cannot be read.", "application-identity-unknown"); + } + json::Value identity = json::Value::Object(); + identity.Set("processId", json::Value::Integer(processId)); + identity.Set("processStartedAt", json::Value::String(FormatFileTimeIso8601(started))); + identity.Set("executablePath", json::Value::String(CanonicalizeExecutablePath(image))); + identity.Set("interactiveSession", json::Value::String("windows-session:" + std::to_string(session))); + UINT32 length = 0; + const LONG measured = GetApplicationUserModelId(process, &length, nullptr); + if (measured != APPMODEL_ERROR_NO_APPLICATION) { + const std::wstring aumid = measured == ERROR_INSUFFICIENT_BUFFER ? ReadProcessApplicationUserModelId(process) : std::wstring(); + if (aumid.empty()) { + throw HelperError(kErrorAttachFailed, "The application's packaged identity cannot be verified.", "application-identity-unknown"); + } + identity.Set("aumid", json::Value::String(aumid)); + } + return identity; +} + +bool BoundedIdentityText(const json::Value* value) { + if (value == nullptr || !value->IsString() || value->AsString().empty() || value->AsString().size() > 4096 || + !IsStrictUtf8(value->AsString())) { + return false; + } + const std::wstring text = value->AsWide(); + return std::none_of(text.begin(), text.end(), + [](wchar_t character) { return character < 0x20 || (character >= 0x7f && character <= 0x9f); }); +} + +void RequireWindowsEndpoint(const json::Value& params) { + const std::string endpoint = params.StringField("endpoint", "windows"); + if (endpoint != "windows" && endpoint != "win32") Fail(kErrorInvalidParams, "The Windows provider requires a Windows endpoint."); +} + +void RequireResolvedPrimary(HWND window, DWORD expectedPid, const std::wstring& title, HANDLE process) { + DWORD actualPid = 0; + GetWindowThreadProcessId(window, &actualPid); + if (actualPid != expectedPid || ReadWindowTitle(window) != title || !IsProcessAlive(process)) { + throw HelperError(kErrorAttachFailed, "The resolved application/window identity changed.", "application-identity-changed"); + } +} + +DWORD RequireExactIdentity(const json::Value& identity) { + if (!identity.IsObject() || identity.Members().size() < 4 || identity.Members().size() > 5) { + throw HelperError(kErrorInvalidParams, "Windows processIdentity requires exact native process, executable and session evidence.", + "application-identity-invalid"); + } + for (const auto& member : identity.Members()) { + if (member.first != "processId" && member.first != "processStartedAt" && member.first != "executablePath" && + member.first != "interactiveSession" && member.first != "aumid") { + throw HelperError(kErrorInvalidParams, "Windows processIdentity contains an unknown field.", "application-identity-invalid"); + } + } + const json::Value* pid = identity.Find("processId"); + const double number = pid == nullptr ? 0 : pid->AsNumber(); + FILETIME started{}; + const std::string timestamp = identity.StringField("processStartedAt"); + const std::string session = identity.StringField("interactiveSession"); + if (pid == nullptr || !pid->IsNumber() || !std::isfinite(number) || std::floor(number) != number || + number < 1 || number > 2147483647 || !TryParseIso8601(timestamp, started) || + FormatFileTimeIso8601(started) != timestamp || !BoundedIdentityText(identity.Find("executablePath")) || + !std::filesystem::path(identity.WideField("executablePath")).is_absolute() || + !session.starts_with("windows-session:") || session.size() < 17 || session.size() > 26 || + !std::all_of(session.begin() + 16, session.end(), [](char character) { return character >= '0' && character <= '9'; }) || + (identity.Has("aumid") && !BoundedIdentityText(identity.Find("aumid")))) { + throw HelperError(kErrorInvalidParams, "Windows processIdentity is incomplete or not in native canonical form.", + "application-identity-invalid"); + } + return static_cast(number); +} + +void VerifyExactIdentity(const json::Value& expected, const json::Value& actual) { + if (expected.StringField("processStartedAt") != actual.StringField("processStartedAt")) { + throw HelperError(kErrorAttachFailed, "The recorded PID names a different process incarnation.", "identity-start-mismatch"); + } + if (expected.StringField("interactiveSession") != actual.StringField("interactiveSession")) { + throw HelperError(kErrorAttachFailed, "The recorded application session differs.", "identity-session-mismatch"); + } + if (_wcsicmp(expected.WideField("executablePath").c_str(), actual.WideField("executablePath").c_str()) != 0) { + throw HelperError(kErrorAttachFailed, "The recorded application executable differs.", "identity-executable-mismatch"); + } + if (expected.StringField("aumid") != actual.StringField("aumid")) { + throw HelperError(kErrorAttachFailed, "The recorded application AUMID differs.", "identity-aumid-mismatch"); + } +} + +std::shared_ptr AcquireApplicationControl(DWORD processId, const FILETIME& started) { + // No waiter retains a handle: after a crash the last handle closes and the semaphore disappears. A semaphore, + // unlike a recursive mutex, also excludes another manager on the same thread. + const std::wstring name = L"Local\\FurnDesktopDriverApplication-v1-" + std::to_wstring(processId) + L"-" + + std::to_wstring(FileTimeTicks(started)); + winrt::handle semaphore{CreateSemaphoreW(nullptr, 1, 1, name.c_str())}; + if (!semaphore) { + throw HelperError(kErrorAttachFailed, "The exact application control lock cannot be opened.", "application-lock-unavailable"); + } + const DWORD waited = WaitForSingleObject(semaphore.get(), 0); + if (waited != WAIT_OBJECT_0) { + throw HelperError(kErrorAttachFailed, "Another native service controls this exact application incarnation.", + waited == WAIT_TIMEOUT ? "application-lock-busy" : "application-lock-unavailable"); + } + auto lock = std::make_shared(semaphore.get(), processId, started); + semaphore.detach(); + return lock; +} + bool ProcessStartedForLaunch(HANDLE process, const FILETIME& launchStartedAt) { FILETIME processStartedAt{}; if (!TryReadProcessStartTime(process, processStartedAt)) { @@ -930,6 +1056,13 @@ bool RestoreWindow(HWND window, const CancellationToken& token) { } } +ApplicationControlLock::~ApplicationControlLock() { + if (handle != nullptr) { + ReleaseSemaphore(handle, 1, nullptr); + CloseHandle(handle); + } +} + ApplicationManager::~ApplicationManager() { try { const CancellationToken idle; @@ -1077,7 +1210,7 @@ const ApplicationLease& ApplicationManager::AdoptWindowForTesting(HWND window, c ApplicationLease& ApplicationManager::CreateLease(std::string ownership, DWORD processId, HANDLE process, HWND primaryWindow, const std::wstring& title, const json::Value& params, std::string containment, - HANDLE job) { + HANDLE job, std::shared_ptr launchRootControl) { ApplicationLease lease; lease.id = NextIdentifier("lease"); lease.ownership = std::move(ownership); @@ -1087,12 +1220,38 @@ ApplicationLease& ApplicationManager::CreateLease(std::string ownership, DWORD p lease.processId = processId; lease.primaryWindow = primaryWindow; lease.windowTitle = title; + lease.launchRootControl = std::move(launchRootControl); // The start time proves which incarnation of the process the lease names; a // lease without it would later read as a process that already exited. if (process != nullptr && !TryReadProcessStartTime(process, lease.startTime)) { Fail(lease.ownership == "launched" ? kErrorLaunchFailed : kErrorAttachFailed, "The leased application process start time could not be read."); } + const json::Value identity = ReadExactApplicationIdentity(processId, process); + lease.executablePath = identity.WideField("executablePath"); + lease.aumid = identity.WideField("aumid"); + lease.interactiveSession = identity.StringField("interactiveSession"); + if (lease.ownership == "launched") { + lease.launchKind = lease.containment == kContainmentJobObject ? "direct-child" : "packaged-activation"; + } + if (lease.launchRootControl && lease.launchRootControl->processId == processId && + FileTimeTicks(lease.launchRootControl->startTime) == FileTimeTicks(lease.startTime)) { + lease.control = lease.launchRootControl; + } + for (const auto& entry : leases_) { + if (entry.second.processId == processId && FileTimeTicks(entry.second.startTime) == FileTimeTicks(lease.startTime)) { + lease.control = entry.second.control; + break; + } + if (entry.second.launchRootControl && entry.second.launchRootControl->processId == processId && + FileTimeTicks(entry.second.launchRootControl->startTime) == FileTimeTicks(lease.startTime)) { + lease.control = entry.second.launchRootControl; + break; + } + } + if (!lease.control) { + lease.control = AcquireApplicationControl(processId, lease.startTime); + } const std::string id = lease.id; leases_.emplace(id, std::move(lease)); ApplicationLease& stored = leases_[id]; @@ -1145,10 +1304,33 @@ ApplicationLease& ApplicationManager::CreateLease(std::string ownership, DWORD p } const ApplicationLease& ApplicationManager::Launch(const json::Value& params, const CancellationToken& token) { + launchFailureData_ = json::Value::Object(); + launchFailureData_.Set("applicationSettlement", json::Value::String(std::string("not-started"))); + try { + return LaunchImpl(params, token); + } catch (HelperError& error) { + error.SetData(launchFailureData_); + throw; + } catch (CancelledError& error) { + error.SetData(launchFailureData_); + throw; + } catch (const std::exception& error) { + HelperError failure(kErrorLaunchFailed, error.what()); + failure.SetData(launchFailureData_); + throw failure; + } +} + +const ApplicationLease& ApplicationManager::LaunchImpl(const json::Value& params, const CancellationToken& token) { + token.ThrowIfCancelled(); + RequireWindowsEndpoint(params); const json::Value* application = params.Find("application"); if (application == nullptr || !application->IsObject()) { Fail(kErrorInvalidParams, "The launch request is missing its application descriptor."); } + if (application->Has("processIdentity") || application->Has("leasePath") || application->Has("leaseNonce")) { + Fail(kErrorInvalidParams, "An external application identity cannot authorize a launch."); + } const json::Value* timeout = params.Find("launchTimeoutMs"); const double configuredTimeout = params.NumberField("launchTimeoutMs", kLaunchTimeoutMs); if ((timeout != nullptr && !timeout->IsNumber()) || !std::isfinite(configuredTimeout) || @@ -1201,6 +1383,7 @@ const ApplicationLease& ApplicationManager::Launch(const json::Value& params, co // Null for packaged activation: Windows creates that process, so the helper // owns only the exact activated process and never claims its descendants. winrt::handle containmentJob; + std::shared_ptr launchControl; try { if (!aumid.empty()) { winrt::com_ptr manager; @@ -1208,6 +1391,7 @@ const ApplicationLease& ApplicationManager::Launch(const json::Value& params, co CoCreateInstance(CLSID_ApplicationActivationManager, nullptr, CLSCTX_LOCAL_SERVER, IID_PPV_ARGS(manager.put()))); CoAllowSetForegroundWindow(manager.get(), nullptr); + launchFailureData_.Set("applicationSettlement", json::Value::String(std::string("unknown"))); CheckHresult(kErrorLaunchFailed, "Activating the packaged application", manager->ActivateApplication(aumid.c_str(), arguments.empty() ? nullptr : arguments.c_str(), AO_NOERRORUI, &launchRoot.processId)); @@ -1222,8 +1406,9 @@ const ApplicationLease& ApplicationManager::Launch(const json::Value& params, co "The activated application process identity could not be read."); } if (!ProcessStartedForLaunch(launchRoot.process.get(), launchStartedAt)) { - Fail(kErrorLaunchFailed, - "Packaged activation reused a pre-existing process. Use attach mode instead."); + launchFailureData_.Set("applicationSettlement", json::Value::String(std::string("not-started"))); + throw HelperError(kErrorLaunchFailed, "Packaged activation reused a pre-existing process. Use attach mode instead.", + "application-preexisting"); } // The root anchors ancestry and is ended on failure, so a process that // does not prove the requested identity, such as one that reused the @@ -1233,6 +1418,13 @@ const ApplicationLease& ApplicationManager::Launch(const json::Value& params, co "The activated process does not report the requested application identity, so the helper left " "it running and did not lease it."); } + launchFailureData_ = ReadExactApplicationIdentity(launchRoot.processId, launchRoot.process.get()); + launchFailureData_.Set("applicationSettlement", json::Value::String(std::string("unknown"))); + launchFailureData_.Set("launchKind", json::Value::String(std::string("packaged-activation"))); + launchFailureData_.Set("containment", json::Value::String(std::string(kContainmentUncontained))); + // Concurrent activations can return the same new process. Losing control + // must not grant cleanup authority over the winning service's application. + launchControl = AcquireApplicationControl(launchRoot.processId, launchRoot.startTime); launchRoot.trusted = true; winrt::handle cleanupProcess = DuplicateProcessHandle(launchRoot.process.get()); if (!cleanupProcess) { @@ -1265,6 +1457,7 @@ const ApplicationLease& ApplicationManager::Launch(const json::Value& params, co mainThread.attach(information.hThread); winrt::handle created; created.attach(information.hProcess); + launchFailureData_.Set("applicationSettlement", json::Value::String(std::string("unknown"))); launchRoot.processId = information.dwProcessId; launchRoot.process = DuplicateProcessHandle(created.get()); const DWORD duplicateError = launchRoot.process ? ERROR_SUCCESS : GetLastError(); @@ -1289,6 +1482,11 @@ const ApplicationLease& ApplicationManager::Launch(const json::Value& params, co Fail(kErrorLaunchFailed, "The created application process identity could not be read."); } + launchFailureData_ = ReadExactApplicationIdentity(launchRoot.processId, launchRoot.process.get()); + launchFailureData_.Set("applicationSettlement", json::Value::String(std::string("unknown"))); + launchFailureData_.Set("launchKind", json::Value::String(std::string("direct-child"))); + launchFailureData_.Set("containment", json::Value::String(std::string(kContainmentJobObject))); + launchControl = AcquireApplicationControl(launchRoot.processId, launchRoot.startTime); if (ResumeThread(mainThread.get()) == static_cast(-1)) { FailLastError(kErrorLaunchFailed, "Resuming the contained unpackaged application", GetLastError()); @@ -1336,8 +1534,8 @@ const ApplicationLease& ApplicationManager::Launch(const json::Value& params, co candidates.emplace_back(window, owner); } if (candidates.size() > 1) { - Fail(kErrorAmbiguousTarget, - "The launch created multiple matching windows titled \"" + ToUtf8(windowTitle) + "\"."); + throw HelperError(kErrorAmbiguousTarget, "The launch created multiple matching application windows.", + "application-identity-ambiguous"); } if (candidates.size() == 1) { matched = candidates.front().first; @@ -1377,11 +1575,21 @@ const ApplicationLease& ApplicationManager::Launch(const json::Value& params, co } } const char* containment = containmentJob ? kContainmentJobObject : kContainmentUncontained; - const ApplicationLease& lease = CreateLease("launched", matchedProcessId, owned.get(), matched, - windowTitle, params, containment, containmentJob.get()); - owned.detach(); - containmentJob.detach(); - return lease; + try { + launchFailureData_ = ReadExactApplicationIdentity(matchedProcessId, owned.get()); + launchFailureData_.Set("applicationSettlement", json::Value::String(std::string("unknown"))); + launchFailureData_.Set("launchKind", json::Value::String(std::string(containmentJob ? "direct-child" : "packaged-activation"))); + launchFailureData_.Set("containment", json::Value::String(std::string(containment))); + RequireResolvedPrimary(matched, matchedProcessId, windowTitle, owned.get()); + const ApplicationLease& lease = CreateLease("launched", matchedProcessId, owned.get(), matched, + windowTitle, params, containment, containmentJob.get(), launchControl); + owned.detach(); + containmentJob.detach(); + return lease; + } catch (...) { + launchProcesses.Track(matchedProcessId, std::move(owned)); + throw; + } } catch (...) { const std::exception_ptr original = std::current_exception(); std::string cleanupFailure; @@ -1401,11 +1609,17 @@ const ApplicationLease& ApplicationManager::Launch(const json::Value& params, co for (TrackedLaunchProcess& tracked : launchProcesses.ReleaseRemaining()) { TrackPendingCleanupProcess(tracked.processId, tracked.process.detach()); } + if (cleanupFailure.empty() && (launchRoot.trusted || containmentJob)) { + launchFailureData_.Set("applicationSettlement", json::Value::String(std::string("exited"))); + } RethrowWithCleanup(original, cleanupFailure); } } -const ApplicationLease& ApplicationManager::Attach(const json::Value& params, const CancellationToken& token) { +ApplicationManager::ResolvedAttachment ApplicationManager::ResolveAttachment(const json::Value& params, + const CancellationToken& token) { + token.ThrowIfCancelled(); + RequireWindowsEndpoint(params); const json::Value* application = params.Find("application"); if (application == nullptr || !application->IsObject()) { Fail(kErrorInvalidParams, "The attach request is missing its application descriptor."); @@ -1415,6 +1629,34 @@ const ApplicationLease& ApplicationManager::Attach(const json::Value& params, co std::wstring windowTitle = application->WideField("windowTitle"); RequireInteractiveWindowStation(kErrorUnsupported); + if (const json::Value* identity = application->Find("processIdentity"); identity != nullptr) { + if (!leasePath.empty() || application->Has("leaseNonce") || windowTitle.empty()) { + Fail(kErrorInvalidParams, "An exact processIdentity attach requires a title and cannot use a legacy lease."); + } + const DWORD processId = RequireExactIdentity(*identity); + winrt::handle process = OpenProcessForIdentity(processId, kErrorAttachFailed); + const json::Value actual = ReadExactApplicationIdentity(processId, process.get()); + VerifyExactIdentity(*identity, actual); + const std::wstring configuredImage = application->WideField("executablePath"); + const std::wstring configuredAumid = application->WideField("aumid"); + if ((!configuredImage.empty() && + _wcsicmp(CanonicalizeExecutablePath(configuredImage).c_str(), actual.WideField("executablePath").c_str()) != 0) || + (!configuredAumid.empty() && configuredAumid != actual.WideField("aumid"))) { + throw HelperError(kErrorAttachFailed, "The descriptor differs from the exact process identity.", "application-identity-mismatch"); + } + std::vector matches; + for (const HWND window : EnumerateTopLevelWindows(processId)) { + token.ThrowIfCancelled(); + if (ReadWindowTitle(window) == windowTitle) matches.push_back(window); + } + if (matches.size() != 1) { + throw HelperError(matches.empty() ? kErrorAttachFailed : kErrorAmbiguousTarget, + "The exact application must have one matching primary window.", + matches.empty() ? "application-window-missing" : "application-identity-ambiguous"); + } + return {processId, std::move(process), matches.front(), windowTitle}; + } + if (!leasePath.empty()) { const std::string contents = ReadFileWithRetry(leasePath, token); if (TrimAscii(contents).empty()) { @@ -1507,10 +1749,7 @@ const ApplicationLease& ApplicationManager::Attach(const json::Value& params, co "The leased application shows " + std::to_string(matches.size()) + " windows titled \"" + ToUtf8(windowTitle) + "\"."); } - const ApplicationLease& attached = CreateLease("attached", processId, process.get(), matches.front(), - windowTitle, params, kContainmentExternal); - process.detach(); - return attached; + return {processId, std::move(process), matches.front(), windowTitle}; } if (windowTitle.empty()) { @@ -1561,17 +1800,16 @@ const ApplicationLease& ApplicationManager::Attach(const json::Value& params, co } } if (unverifiable > 0) { - Fail(kErrorAmbiguousTarget, - std::to_string(unverifiable) + " window(s) titled \"" + ToUtf8(windowTitle) + - "\" belong to a process whose identity cannot be read, so the target cannot be proven unique."); + throw HelperError(kErrorAmbiguousTarget, "A matching window's process identity cannot be read; uniqueness is unknown.", + "application-identity-unknown"); } if (matches.empty()) { - Fail(kErrorAttachFailed, "No window titled \"" + ToUtf8(windowTitle) + "\" is currently open."); + throw HelperError(kErrorAttachFailed, "No process/window matches the configured application.", + "no-matching-application"); } if (matches.size() > 1) { - Fail(kErrorAmbiguousTarget, - std::to_string(matches.size()) + " windows are titled \"" + ToUtf8(windowTitle) + - "\", so the target is ambiguous."); + throw HelperError(kErrorAmbiguousTarget, "Multiple process/window candidates match the application descriptor.", + "application-identity-ambiguous"); } DWORD processId = 0; GetWindowThreadProcessId(matches.front(), &processId); @@ -1579,12 +1817,86 @@ const ApplicationLease& ApplicationManager::Attach(const json::Value& params, co !ProcessMatchesLaunchIdentity(matchedProcess.get(), aumid, executablePath)) { Fail(kErrorAttachFailed, "The application identity changed while attaching."); } - const ApplicationLease& attached = CreateLease("attached", processId, matchedProcess.get(), matches.front(), - windowTitle, params, kContainmentExternal); - matchedProcess.detach(); + return {processId, std::move(matchedProcess), matches.front(), windowTitle}; +} + +const ApplicationLease& ApplicationManager::Attach(const json::Value& params, const CancellationToken& token) { + ResolvedAttachment resolved = ResolveAttachment(params, token); + RequireResolvedPrimary(resolved.window, resolved.processId, resolved.title, resolved.process.get()); + const ApplicationLease& attached = CreateLease("attached", resolved.processId, resolved.process.get(), resolved.window, + resolved.title, params, kContainmentExternal); + resolved.process.detach(); return attached; } +json::Value ApplicationManager::InspectApplication(const json::Value& params, const CancellationToken& token) { + ResolvedAttachment resolved = ResolveAttachment(params, token); + token.ThrowIfCancelled(); + const json::Value identity = ReadExactApplicationIdentity(resolved.processId, resolved.process.get()); + RequireResolvedPrimary(resolved.window, resolved.processId, resolved.title, resolved.process.get()); + return identity; +} + +json::Value ApplicationManager::RecoverApplication(const json::Value& params, const CancellationToken& token) { + RequireWindowsEndpoint(params); + const json::Value* identity = params.Find("identity"); + if (identity == nullptr) Fail(kErrorInvalidParams, "Exact application recovery requires its recorded identity."); + const DWORD processId = RequireExactIdentity(*identity); + token.ThrowIfCancelled(); + RequireInteractiveWindowStation(kErrorUnsupported); + const auto exited = [] { + json::Value result = json::Value::Object(); + result.Set("state", json::Value::String(std::string("exited"))); + return result; + }; + winrt::handle query{OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, processId)}; + if (!query) { + if (GetLastError() == ERROR_INVALID_PARAMETER) return exited(); + throw HelperError(kErrorCleanupFailed, "The recorded application's creation time cannot be inspected.", + "application-identity-unknown"); + } + FILETIME started{}; + if (!TryReadProcessStartTime(query.get(), started) || FileTimeTicks(started) == 0) { + throw HelperError(kErrorCleanupFailed, "The application start identity cannot be read.", "application-identity-unknown"); + } + if (FormatFileTimeIso8601(started) != identity->StringField("processStartedAt")) { + // A replacement proves the recorded incarnation exited. Never request termination rights or control of it. + return exited(); + } + // Keep the query handle open: its process object prevents PID reuse while upgrading access for exact cleanup. + winrt::handle process{OpenProcess(kOwnedProcessAccess, FALSE, processId)}; + if (!process && GetLastError() != ERROR_INVALID_PARAMETER) { + throw HelperError(kErrorCleanupFailed, "The recorded application process cannot be inspected.", "application-identity-unknown"); + } + if (process) { + const DWORD state = WaitForSingleObject(process.get(), 0); + if (state != WAIT_OBJECT_0 && state != WAIT_TIMEOUT) { + throw HelperError(kErrorCleanupFailed, "The recorded application's liveness cannot be proven.", "application-identity-unknown"); + } + if (state == WAIT_OBJECT_0) { + return exited(); + } + VerifyExactIdentity(*identity, ReadExactApplicationIdentity(processId, process.get())); + const auto control = AcquireApplicationControl(processId, started); + token.ThrowIfCancelled(); + PostCloseToExactProcessWindows(processId, started); + const Deadline graceful(kCloseTimeoutMs); + while (IsProcessAlive(process.get()) && !graceful.Expired()) token.Wait(50); + if (IsProcessAlive(process.get())) { + token.ThrowIfCancelled(); + if (TerminateProcess(process.get(), 0) == FALSE && IsProcessAlive(process.get())) { + throw HelperError(kErrorCleanupFailed, "The recorded application could not be ended.", "application-recovery-unsettled"); + } + const Deadline forced(kTerminateWaitMs); + while (IsProcessAlive(process.get()) && !forced.Expired()) token.Wait(50); + if (IsProcessAlive(process.get())) { + throw HelperError(kErrorCleanupFailed, "Exact application exit was not confirmed.", "application-recovery-unsettled"); + } + } + } + return exited(); +} + std::vector ApplicationManager::Windows(const std::string& leaseId, const CancellationToken& token) { const auto entry = leases_.find(leaseId); if (entry == leases_.end()) { @@ -2025,6 +2337,10 @@ json::Value ApplicationManager::SerializeLease(const ApplicationLease& lease) co if (!started.empty()) { value.Set("processStartedAt", json::Value::String(started)); } + value.Set("executablePath", json::Value::String(lease.executablePath)); + value.Set("interactiveSession", json::Value::String(lease.interactiveSession)); + if (!lease.aumid.empty()) value.Set("aumid", json::Value::String(lease.aumid)); + if (!lease.launchKind.empty()) value.Set("launchKind", json::Value::String(lease.launchKind)); return value; } diff --git a/packages/agentic/desktop-driver/native/windows/src/application.h b/packages/agentic/desktop-driver/native/windows/src/application.h index 68c1bb0325..0ec6232857 100644 --- a/packages/agentic/desktop-driver/native/windows/src/application.h +++ b/packages/agentic/desktop-driver/native/windows/src/application.h @@ -4,12 +4,14 @@ #include #include +#include #include #include #include #include #include #include +#include #include "common.h" #include "events.h" @@ -37,6 +39,16 @@ inline constexpr char kContainmentJobObject[] = "job-object"; inline constexpr char kContainmentUncontained[] = "uncontained"; inline constexpr char kContainmentExternal[] = "external"; +struct ApplicationControlLock { + ApplicationControlLock(HANDLE value, DWORD pid, FILETIME started) : handle(value), processId(pid), startTime(started) {} + ApplicationControlLock(const ApplicationControlLock&) = delete; + ApplicationControlLock& operator=(const ApplicationControlLock&) = delete; + ~ApplicationControlLock(); + HANDLE handle{nullptr}; + DWORD processId{0}; + FILETIME startTime{}; +}; + struct ApplicationLease { std::string id; std::string ownership; // "attached" or "launched" @@ -45,6 +57,12 @@ struct ApplicationLease { std::string storyRootTestId; DWORD processId{0}; FILETIME startTime{}; + std::wstring executablePath; + std::wstring aumid; + std::string interactiveSession; + std::string launchKind; + std::shared_ptr control; + std::shared_ptr launchRootControl; std::wstring windowTitle; HANDLE process{nullptr}; // The containment Job Object of a kContainmentJobObject launch, else null. @@ -62,6 +80,8 @@ class ApplicationManager { const ApplicationLease& Launch(const json::Value& params, const CancellationToken& token); const ApplicationLease& Attach(const json::Value& params, const CancellationToken& token); + json::Value InspectApplication(const json::Value& params, const CancellationToken& token); + json::Value RecoverApplication(const json::Value& params, const CancellationToken& token); void CloseApplication(const std::string& leaseId, const CancellationToken& token); void Dispose(const CancellationToken& token); std::vector Windows(const std::string& leaseId, const CancellationToken& token); @@ -103,6 +123,15 @@ class ApplicationManager { } private: + struct ResolvedAttachment { + DWORD processId{0}; + winrt::handle process; + HWND window{nullptr}; + std::wstring title; + }; + ResolvedAttachment ResolveAttachment(const json::Value& params, const CancellationToken& token); + const ApplicationLease& LaunchImpl(const json::Value& params, const CancellationToken& token); + json::Value launchFailureData_{json::Value::Object()}; static constexpr std::size_t kMaximumRetiredWindowIds = 256; struct WindowRecord { @@ -128,7 +157,8 @@ class ApplicationManager { json::Value PublicationDiagnostic(const ApplicationLease& lease) const; ApplicationLease& CreateLease(std::string ownership, DWORD processId, HANDLE process, HWND primaryWindow, const std::wstring& title, const json::Value& params, - std::string containment, HANDLE job = nullptr); + std::string containment, HANDLE job = nullptr, + std::shared_ptr launchRootControl = {}); void TrackPendingCleanupProcess(DWORD processId, HANDLE process); struct PendingCleanupProcess { diff --git a/packages/agentic/desktop-driver/native/windows/src/common.h b/packages/agentic/desktop-driver/native/windows/src/common.h index 2652bdf49f..964e0c9010 100644 --- a/packages/agentic/desktop-driver/native/windows/src/common.h +++ b/packages/agentic/desktop-driver/native/windows/src/common.h @@ -13,6 +13,8 @@ #include #include +#include "json.h" + namespace furn { // Wire error codes shared with the Node adapter. "unsupported-operation" and @@ -81,11 +83,14 @@ class HelperError : public std::runtime_error { // Set only when a mutating command fails; reads and lifecycle commands carry none. const std::optional& effect() const noexcept { return effect_; } void SetEffect(MutationEffect effect) noexcept { effect_ = effect; } + const json::Value& data() const noexcept { return data_; } + void SetData(json::Value data) { data_ = std::move(data); } private: std::string code_; std::string reason_; std::optional effect_; + json::Value data_; }; [[noreturn]] void Fail(std::string code, std::string message); @@ -101,9 +106,12 @@ class CancelledError : public std::exception { // Set only when a mutating command is cancelled. const std::optional& effect() const noexcept { return effect_; } void SetEffect(MutationEffect effect) noexcept { effect_ = effect; } + const json::Value& data() const noexcept { return data_; } + void SetData(json::Value data) { data_ = std::move(data); } private: std::optional effect_; + json::Value data_; }; class CancellationToken { diff --git a/packages/agentic/desktop-driver/native/windows/src/driver.cpp b/packages/agentic/desktop-driver/native/windows/src/driver.cpp index acfc88a474..f7a2ae79c2 100644 --- a/packages/agentic/desktop-driver/native/windows/src/driver.cpp +++ b/packages/agentic/desktop-driver/native/windows/src/driver.cpp @@ -599,7 +599,7 @@ CommandResult Driver::Execute(const std::string& command, const json::Value& par CommandResult Driver::ExecuteCommand(const std::string& command, const json::Value& params, const CancellationToken& token) { CommandResult result; - token.ThrowIfCancelled(); + if (command != "launch") token.ThrowIfCancelled(); if (IsGuardedMutation(command)) { RequireMutationsAllowed(); } @@ -614,6 +614,11 @@ CommandResult Driver::ExecuteCommand(const std::string& command, const json::Val result.result = applications_.SerializeLease(lease); return result; } + if (command == "inspectApplication" || command == "recoverApplication") { + result.result = command == "inspectApplication" ? applications_.InspectApplication(params, token) + : applications_.RecoverApplication(params, token); + return result; + } if (command == "closeApplication") { const json::Value* lease = params.Find("lease"); if (lease == nullptr || !lease->IsObject()) { diff --git a/packages/agentic/desktop-driver/native/windows/src/host.cpp b/packages/agentic/desktop-driver/native/windows/src/host.cpp index 2a211f7c2e..f8b3de819e 100644 --- a/packages/agentic/desktop-driver/native/windows/src/host.cpp +++ b/packages/agentic/desktop-driver/native/windows/src/host.cpp @@ -31,6 +31,17 @@ std::string BoundedField(std::string_view value, std::size_t maximumBytes) { return value.size() <= maximumBytes ? std::string(value) : std::string(value.substr(0, maximumBytes)) + "..."; } +json::Value ApplicationLifecycleData(const json::Value& value) { + json::Value result = json::Value::Object(); + for (const char* key : {"applicationSettlement", "processId", "processStartedAt", "executablePath", + "interactiveSession", "aumid", "launchKind", "containment"}) { + if (const json::Value* field = value.Find(key); field != nullptr) { + result.Set(key, field->IsString() ? json::Value::String(BoundedField(field->AsString(), 4096)) : *field); + } + } + return result; +} + } // namespace json::Value CreateHello() { @@ -50,7 +61,8 @@ json::Value CreateHello() { json::Value features = json::Value::Array(); for (const char* feature : {"accessibilityActions", "accessibilityClick", "performAccessibilityAction", "activeElement", "boundedObservation", "capture", "elementCapture", - "find", "hitTest", "keyboard", "launch", "attach", "pointer", "probe", "releaseActions", + "find", "hitTest", "keyboard", "launch", "attach", "inspectApplication", "recoverApplication", + "pointer", "probe", "releaseActions", "setWindowRect", "source", "strictClear", "tree", "wheel", "windowEvents"}) { features.Append(json::Value::String(std::string(feature))); } @@ -125,12 +137,12 @@ bool Host::WriteResponse(const std::string& id, const std::string& command, cons bool Host::WriteError(const std::string& id, const std::string& command, const std::string& code, const std::string& message, std::optional effect, - const std::string& reason) noexcept { + const std::string& reason, const json::Value& lifecycle) noexcept { try { json::Value error = json::Value::Object(); error.Set("code", json::Value::String(code)); error.Set("message", json::Value::String(BoundedField(message, 4096))); - json::Value data = json::Value::Object(); + json::Value data = ApplicationLifecycleData(lifecycle); data.Set("command", json::Value::String(BoundedField(command, 256))); if (!reason.empty()) { data.Set("reason", json::Value::String(BoundedField(reason, 128))); @@ -155,11 +167,13 @@ bool Host::WriteError(const std::string& id, const std::string& command, const s } } -bool Host::WriteCancelled(const std::string& id, std::optional effect) noexcept { +bool Host::WriteCancelled(const std::string& id, std::optional effect, + const json::Value& lifecycle) noexcept { try { json::Value cancelled = json::Value::Object(); cancelled.Set("type", json::Value::String(std::string("cancelled"))); cancelled.Set("id", json::Value::String(id)); + if (!lifecycle.IsNull()) cancelled.Set("data", ApplicationLifecycleData(lifecycle)); if (effect) { cancelled.Set("effect", json::Value::String(std::string(MutationEffectName(*effect)))); } @@ -176,6 +190,7 @@ bool Host::WriteCancelled(const std::string& id, std::optional e void Host::HandleCancel(const std::string& id) { std::optional effect; + json::Value lifecycle; { const std::lock_guard guard(mutex_); if (activeId_ == id) { @@ -196,12 +211,16 @@ void Host::HandleCancel(const std::string& id) { if (IsTrackedMutation(queued->command)) { effect = MutationEffect::None; } + if (queued->command == "launch") { + lifecycle = json::Value::Object(); + lifecycle.Set("applicationSettlement", json::Value::String(std::string("not-started"))); + } queue_.erase(queued); } } // A queued or already-settled request has no running side effects, so the // acknowledgement can be written immediately. - WriteCancelled(id, effect); + WriteCancelled(id, effect, lifecycle); } void Host::WorkerLoop() { @@ -225,6 +244,7 @@ void Host::WorkerLoop() { bool cancelled = false; std::optional cancelledEffect; + json::Value cancelledLifecycle; bool commandSucceeded = false; bool wroteResult = true; try { @@ -237,8 +257,9 @@ void Host::WorkerLoop() { } catch (const CancelledError& error) { cancelled = true; cancelledEffect = error.effect(); + cancelledLifecycle = error.data(); } catch (const HelperError& error) { - wroteResult = WriteError(request.id, request.command, error.code(), error.what(), error.effect(), error.reason()); + wroteResult = WriteError(request.id, request.command, error.code(), error.what(), error.effect(), error.reason(), error.data()); } catch (const winrt::hresult_error& error) { wroteResult = WriteError(request.id, request.command, kErrorInternal, ToUtf8(error.message().c_str())); } catch (const std::exception& error) { @@ -247,7 +268,7 @@ void Host::WorkerLoop() { if (cancelled) { driver_.ReleaseInput(); - wroteResult = WriteCancelled(request.id, cancelledEffect); + wroteResult = WriteCancelled(request.id, cancelledEffect, cancelledLifecycle); } { diff --git a/packages/agentic/desktop-driver/native/windows/src/host.h b/packages/agentic/desktop-driver/native/windows/src/host.h index 13bc679c48..2ddd428340 100644 --- a/packages/agentic/desktop-driver/native/windows/src/host.h +++ b/packages/agentic/desktop-driver/native/windows/src/host.h @@ -45,8 +45,9 @@ class Host { bool WriteResponse(const std::string& id, const std::string& command, const CommandResult& result) noexcept; bool WriteError(const std::string& id, const std::string& command, const std::string& code, const std::string& message, std::optional effect = std::nullopt, - const std::string& reason = {}) noexcept; - bool WriteCancelled(const std::string& id, std::optional effect) noexcept; + const std::string& reason = {}, const json::Value& lifecycle = json::Value::Null()) noexcept; + bool WriteCancelled(const std::string& id, std::optional effect, + const json::Value& lifecycle = json::Value::Null()) noexcept; void RecordWriteFailure(std::string_view operation, const char* message) noexcept; void PublishWindowClosed(const std::string& windowId) noexcept; diff --git a/packages/agentic/desktop-driver/native/windows/src/selftest.cpp b/packages/agentic/desktop-driver/native/windows/src/selftest.cpp index bea2b6c11f..c62fb718df 100644 --- a/packages/agentic/desktop-driver/native/windows/src/selftest.cpp +++ b/packages/agentic/desktop-driver/native/windows/src/selftest.cpp @@ -373,7 +373,8 @@ void TestLeaseIdentity() { try { applications.Launch(request, token); } catch (const HelperError& error) { - rejected = std::string(error.code()) == kErrorInvalidParams; + rejected = std::string(error.code()) == kErrorInvalidParams && + error.data().StringField("applicationSettlement") == "not-started"; } Check(rejected, "invalid launch deadlines are rejected before application side effects"); } @@ -384,6 +385,14 @@ void TestLeaseIdentity() { titleOnlyRejected = std::string(error.code()) == kErrorInvalidParams; } Check(titleOnlyRejected, "unleased title-only attach is rejected"); + token.Cancel(); + bool cancelledBeforeLaunch = false; + try { + applications.Launch(json::Value::Object(), token); + } catch (const CancelledError& error) { + cancelledBeforeLaunch = error.data().StringField("applicationSettlement") == "not-started"; + } + Check(cancelledBeforeLaunch, "cancellation before launch reports that no process started"); } void TestHello() { @@ -1321,6 +1330,76 @@ void TestLeaseRegistrationFailure() { applications.CloseApplication(leaseId, CancellationToken{}); } +void TestExactApplicationOwnership() { + WindowThread windows; + const std::wstring title = L"Furn exact application identity"; + const HWND window = windows.Create(L"FurnSelfTestApplicationIdentity", title.c_str()); + ApplicationManager owner; + ApplicationManager observer; + const CancellationToken token; + const std::string leaseId = owner.AdoptWindowForTesting(window, title).id; + const json::Value lease = owner.SerializeLease(*owner.PrimaryLease()); + json::Value request = json::Value::Object(); + json::Value application = json::Value::Object(); + application.Set("executablePath", *lease.Find("executablePath")); + application.Set("windowTitle", json::Value::String(title)); + request.Set("application", application); + request.Set("endpoint", json::Value::String(std::string("win32"))); + const json::Value identity = observer.InspectApplication(request, token); + Check(identity.NumberField("processId") == GetCurrentProcessId() && + identity.StringField("processStartedAt") == lease.StringField("processStartedAt") && + identity.StringField("executablePath") == lease.StringField("executablePath") && + identity.StringField("interactiveSession") == lease.StringField("interactiveSession"), + "read-only discovery retains the full native process incarnation"); + Check(observer.PrimaryLease() == nullptr && owner.WindowIdsForLease(leaseId).size() == 1, + "discovery creates no lease or controlling window records"); + bool excluded = false; + try { + observer.Attach(request, token); + } catch (const HelperError& error) { + excluded = error.reason() == "application-lock-busy"; + } + Check(excluded, "another application manager cannot control an acquired process"); + + FILETIME started{}; + TryParseIso8601(identity.StringField("processStartedAt"), started); + const std::uint64_t staleTicks = FileTimeTicks(started) + 1; + const FILETIME stale{static_cast(staleTicks), static_cast(staleTicks >> 32)}; + json::Value pin = identity; + pin.Set("processStartedAt", json::Value::String(FormatFileTimeIso8601(stale))); + application.Set("processIdentity", pin); + request.Set("application", application); + bool reused = false; + try { + observer.InspectApplication(request, token); + } catch (const HelperError& error) { + reused = error.reason() == "identity-start-mismatch"; + } + Check(reused, "an exact pin rejects even a one-tick native start mismatch"); + json::Value recovery = json::Value::Object(); + recovery.Set("endpoint", json::Value::String(std::string("win32"))); + recovery.Set("identity", pin); + Check(observer.RecoverApplication(recovery, token).StringField("state") == "exited" && + IsWindow(window) != FALSE, + "a reused PID settles the old incarnation without acquiring control or signalling the current process"); + recovery.Set("identity", identity); + bool recoveryExcluded = false; + try { + observer.RecoverApplication(recovery, token); + } catch (const HelperError& error) { + recoveryExcluded = error.reason() == "application-lock-busy"; + } + Check(recoveryExcluded, "matching-start recovery still requires exclusive application control"); + application.Set("processIdentity", identity); + request.Set("application", application); + Check(observer.InspectApplication(request, token).Serialize() == identity.Serialize(), + "an exact pin is readable without transferring control"); + owner.CloseApplication(leaseId, token); + const std::string borrowed = observer.Attach(request, token).id; + observer.CloseApplication(borrowed, token); + Check(IsWindow(window) != FALSE, "releasing control never ends an attached application"); +} + void TestAttachedCloseKeepsQuarantine() { ResetMutationQuarantineForTesting(); WindowThread windows; @@ -3824,7 +3903,8 @@ void TestDriverObservation() { return driver.Execute("releaseElements", params, token).result; }; const auto released = [&](std::vector named) { - const json::Value* count = release(std::move(named)).Find("released"); + const json::Value result = release(std::move(named)); + const json::Value* count = result.Find("released"); return count == nullptr ? -1 : count->AsInteger(-1); }; Check(released({otherButtonId, "element-unknown"}) == 1 && released({otherButtonId}) == 0, @@ -3904,6 +3984,7 @@ int RunSelfTest() { TestDriverObservation(); TestSnapshotShape(); TestLeaseIdentity(); + TestExactApplicationOwnership(); TestHello(); RunAuthoritySelfTests(Check); RunObservationSelfTests(Check); diff --git a/packages/agentic/desktop-driver/references/contributing.md b/packages/agentic/desktop-driver/references/contributing.md index 91a05f1cd0..8fe7d4cbd0 100644 --- a/packages/agentic/desktop-driver/references/contributing.md +++ b/packages/agentic/desktop-driver/references/contributing.md @@ -99,7 +99,7 @@ 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 +- On every native endpoint, 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. diff --git a/packages/agentic/desktop-driver/references/current-status.md b/packages/agentic/desktop-driver/references/current-status.md index a5a875fccc..bc1c134d39 100644 --- a/packages/agentic/desktop-driver/references/current-status.md +++ b/packages/agentic/desktop-driver/references/current-status.md @@ -849,6 +849,414 @@ 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/`. +## Windows Fabric and Win32 Paper — F10-W application ownership — 2026-11-12 UTC + +**Source and dependency:** Windows implementation commit +`6e881e1a05fef735c1b9bacd7d123285f47269ff`, branch +`user/jasonvmo/desktop-windows-app-ownership`, stacked on F10-M #4492 at +exactly `6489386956c0485cb91b478a8f4adc4828f4178b`. The intended draft PR +base is `user/jasonvmo/desktop-macos-app-ownership`; its independently owned +branch must not be rewritten. This implements the same neutral +`applicationOwnership` host/resource model, schema 3 claims, native OS owner +identity, `processIdentity`, `inspectApplication`, and launch settlement +metadata, rather than a parallel Windows contract. Application naming uses +the existing neutral publication seam; no additional F11 #4448/#4491-owned +file changes were made relative to the pinned base. + +**Observed, portable Windows host:** the driver's declared format, lint and +TypeScript build passed. With native opt-in unset, the full driver suite +passed **74 suites / 1,234 tests**, with **9 suites / 93 tests skipped**. +The focused `NativeDesktopHost.applicationIdentity` suite passed **31 tests**, +covering malformed/missing Windows image/session/AUMID/start evidence, +read-only inspection and trusted recovery response/input validation. +Test-first failures were observed before implementing the Windows boundary; +those fake-helper tests do not establish native exclusion or cleanup. + +Commands ran from the worktree root; `$W2` denotes that root. `TEMP`/`TMP` +were scoped to its ignored `artifacts\desktop\f10w\scratch` directory and +`FURN_NATIVE_DRIVER_TEST` was unset. + +| Command | Result | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | +| `yarn workspace @fluentui-react-native/desktop-driver format` | 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-driver test --runInBand --cacheDirectory "$W2\artifacts\desktop\f10w\jest-driver"` | Passed; native opt-ins skipped | +| `yarn workspace @fluentui-react-native/desktop-driver test --runInBand NativeDesktopHost.applicationIdentity --cacheDirectory "$W2\artifacts\desktop\f10w\jest-driver"` | Passed 31 tests | +| `yarn check-agent-instructions` | Passed | +| `yarn oxfmt --check` | Passed repository-wide, 3,163 files | + +Ignored run evidence: `artifacts/desktop/f10w/portable-20261112/driver-test.log`, +`driver-build.log`, `driver-lint.log`, and `changed-markdown-links.log`. +Every changed Markdown file's link check passed. The root formatter +deliberately excludes `.changeset/*.md`; the committed patch changeset +`windows-exact-application-ownership.md` covers driver, harness and Storybook. + +**Observed, compile only:** the locked direct `buildWindowsDriver` Debug/x64 +invocation (`buildId: f10w-compile-02`, staging +`artifacts/desktop/f10w/b2`) passed in **35.09 seconds, 0 warnings / 0 errors**. +All seven changed C++ source/header files match the successful build's source +snapshot byte-for-byte. The first compile failed on a self-test `std::wstring` +argument, corrected to `.c_str()` before the successful build. Native build +log: `artifacts/desktop/f10w/b2/build.log`; output: +`artifacts/desktop/f10w/b2/output/furn-desktop-driver-host.exe`. +No helper self-test, fixture or application was executed by this compile. + +**Implementation, not native qualification:** Windows leases and inspection +retain canonical image, seven-digit native creation time, session and packaged +AUMID. Exact pins reject a one-tick mismatch and ambiguous candidates. A +per-incarnation nonwaiting semaphore excludes controlling helpers; a losing +packaged activation receives no cleanup authority over another controller. +Unpackaged launches retain suspended-create / Job Object containment. +Packaged activation stays explicitly `uncontained`: a post-activation job +cannot remove its already-running race and may encounter existing job +restrictions. Exact primary-process cleanup is not arbitrary-descendant proof. + +| Native acceptance case | Windows Fabric packaged | Win32 Paper REX | Reason / required next evidence | +| ---------------------------------------------------------- | ----------------------- | --------------- | ---------------------------------------------------------------------------- | +| Exact AUMID / image / REX incarnation, start and session | Not run | Not run | Awaiting the lane's granted desktop campaign | +| Native one-tick/session/image rejection and ambiguity | Not run | Not run | Authored native fixture/consumer contracts; portable tests do not prove this | +| Cross-service exact-application exclusion | Not run | Not run | Must use independent real helpers | +| Job containment / explicitly fenced packaged activation | Not run | Not run | Runtime process/lease evidence required | +| Physical input mutex | Not run | Not run | Requires physical opt-in and connected/unlocked session gate | +| Acquired survivor journaling and next-run exact recovery | Not run | Not run | Requires identity-verified helper/owner fault injection | +| Five consecutive launches/teardowns without identity leaks | Not run | Not run | Required separately on both real endpoints | + +Native session kind is **not sampled**: no GUI/physical campaign has run. +Planned provider command, under the exclusive lock: +`$env:FURN_NATIVE_DRIVER_TEST='1'; yarn workspace @fluentui-react-native/desktop-driver test --runInBand applicationOwnership.contract`. +The consumer runbook is +`packages/agentic/desktop-test/references/native-qualification.md`, +“Neutral Windows application ownership”; it additionally exercises three +paired packaged activations and physical input across both endpoint helpers. +The pre-campaign audit observed `FURN-AUDIT: no FURN desktop processes found`; +no W2 campaign was started. These are preparation results, not native or +hosted-CI qualification. Part of #4447 and #4435. + +Preparation closeout on the implementation SHA above: the post-commit +focused driver rerun passed **31 tests**. `yarn change:check` passed; +`yarn lage test-links --verbose --grouped` passed **90 tasks, 0 +failed/skipped**. The two appended Windows ledgers' link checks passed +(driver 6 links, harness 15), and `yarn oxfmt --check` again passed +3,163 files. Only Windows evidence text followed those checks; no native or +runtime source changed. Additional ignored provenance under +`artifacts/desktop/f10w/portable-20261112/`: +`tested-source-revision.txt`, `compile-source-evidence.json`, +`driver-focused-committed.log`, `test-links.log`, and `root-format.log`. + +## Windows Fabric and Win32 Paper — F10-W native qualification — 2026-11-12 UTC + +**Observed source:** application qualification ran at +`8343dc0c48a2de01475454c892e7a295523caa75`; helper/self-test correction +`4a4b87f37b574820b93e342b68721d931be07c55` follows implementation +`6e881e1a05fef735c1b9bacd7d123285f47269ff`. The branch remains +`user/jasonvmo/desktop-windows-app-ownership`, pinned on #4492 at +`6489386956c0485cb91b478a8f4adc4828f4178b`. The coordinator handoff +`W2-base-decision.md` records **STACK**: W2 implements the inherited neutral +application host/resource model, schema-3 native owner claims, exact process pins, +inspection and settlement messages, rather than a second Windows model. +Application naming required no new F11-owned file edits. The eventual draft PR +base remains `user/jasonvmo/desktop-macos-app-ownership`; later dependency +movement does not authorize a rebase, merge or dependency-branch push. + +**Observed host and identity:** Windows build 26300 x64, Node 26.13.0, +active/unlocked RDP (`sessionKind: remote`, `windows-session:1`). Every native +command used the exclusive W2 lock and `-RequirePhysical`; before/after gates +stayed eligible. The actual endpoint helper's SHA256/artifact identity is +`863cc2dbfc8f5792d22c8606376d226affbf8655fe4ded62ba78c23a29c516c7`, +source digest +`ea76e931dc4cdebc87e0621a7d730f81faabce5ef902063a0512cd7c2b100f2d`, +Debug/x64. Resolution was prebuilt-only. + +The registered Fabric build was reused from the coordinator-specified +`furn-81` worktree, built at `bdeb0b291`: no Windows app/native-core Windows +source differs from that build. Its generated `RnwNewArch` and `UseFabric` +properties are true. All five ordinary native identities matched the registered +manifest's exact image and AUMID +`40411fc5-8e92-4d46-b68d-b62df44b1366_syyqehzwccsyy!App`. +Metro and both JavaScript bundles came from W2. Win32 used the canonical +prebuilt REX `ReactTest.exe` and W2's Paper bundle; Win32 native app build is +**Unsupported**, not a skipped build. + +**Observed provider result:** `nativeDriver`'s selected Windows build/reuse/ +resolution/handshake contract and all five new `applicationOwnership` fixture +cases passed: **2 suites / 6 tests passed**, 10 unrelated filtered cases skipped. +This includes the native C++ self-test and the actual physical mutex fixture. +The first self-test run failed because an existing release-count assertion +kept a pointer into a destroyed temporary JSON result. Commit `4a4b87f37` +retains the result for the assertion; the native rerun passed. This is a +self-test lifetime fix, not a change to release behavior. + +From `packages/agentic/desktop-driver`, the exact selected test command was: + +```powershell +yarn test --runInBand --cacheDirectory "$W2\artifacts\desktop\f10w\jest-driver" --runTestsByPath src\native\nativeDriver.test.ts src\native\windows\applicationOwnership.contract.test.ts --testNamePattern 'Windows exact application ownership|builds, reuses, resolves, and handshakes with the Windows helper' --json --outputFile "$W2\artifacts\desktop\f10w\native-20261112\driver-native-fixed-results.json" +``` + +Inherited settings were `FURN_NATIVE_DRIVER_TEST=1`, +`FURN_NATIVE_PHYSICAL_INPUT_TEST=1`, native cache +`$W2\artifacts\desktop\f10w\n`, configuration `debug`, build policy `never`, +and project-local `TEMP`/`TMP`. From `apps/storybook`, +`yarn desktop-driver doctor --platform windows --configuration debug --cache-root "$W2\artifacts\desktop\f10w\n"` +**passed** against the selected artifact. An initial doctor without the explicit +configuration selected its CLI default `release` and correctly reported no +verified prebuilt; that setup attempt was not a native application result. + +The separate real-endpoint consumer suite passed **9 tests / 0 failed / +0 skipped**: + +| Case | Windows Fabric packaged | Win32 Paper REX | +| --------------------------------------------------------------------- | ------------------------------------------------------------------------ | -------------------------------------------------------- | +| Exact AUMID/image/REX incarnation, native start and session | Passed | Passed | +| Start/session/image mismatch; independent exact-controller exclusion | Passed | Passed | +| Multiple actual matching processes/windows refuse unpinned inspection | Passed, three pairs | Passed, three pairs | +| Containment or explicit exact-identity fencing | Passed, `uncontained` and journaled | Passed, `job-object` | +| Physical Shift contention, effect-none cancellation and transfer | Passed | Passed | +| Live-owner fence, acquired journal and next-run recovery | Passed, packaged app survived helper crash then exited by exact recovery | Passed, Job Object drained then the claim was reconciled | +| Repeated launches and settled teardown | Passed 5/5, five distinct incarnations | Passed 5/5, five distinct incarnations | +| Close one concurrent winner without ending the other | Passed 3/3 pairs | Passed 3/3 pairs | + +Both packaged contenders started distinct processes in every pair. Same exact +incarnation exclusion was independently proved by pinned attach, but a +**shared-PID packaged activation race was not induced**. Actual kernel PID +recycling was **Not run**; the real fixture's one-100-ns-tick mismatch and both +endpoints' stale-start refusal passed. Arbitrary uncontained descendants and +mixed Storybook-graph recovery remain outside this bounded qualification. + +Ignored evidence root: `artifacts/desktop/f10w/native-20261112/`. +`driver-native.log` retains the initial failure; +`driver-native-fixed.log` and `driver-native-fixed-results.json` retain the +green provider run. `qualification-summary.json`, `selected-helper.json`, +`application-build-provenance.json` and `consumer-evidence-final.json` record +the tested source, binary hash, registration, bundles and per-case identities. +The real consumer run is `windows-application-ownership-GiW4x1`; outer command +report `4ed98f8f-8ebb-49ca-ac90-2ee6caece7dc` is a lifecycle report, not the +test-count source. The harness ledger records the full consumer commands. + +**Observed closeout:** all W2 campaigns ended; before/after audits were clean: +`FURN-AUDIT: no FURN desktop processes found`. No helper, fixture, Metro, REX +or packaged app from W2 survived. Review, the final one-time clean-cache +`buildci`, push, draft PR and hosted CI remain **Not run** at this review +handoff. The three-package patch changeset remains +`windows-exact-application-ownership.md`. Part of #4447 and #4435. + +## Windows Fabric and Win32 Paper — recovery review follow-up — 2026-11-12 UTC + +**Scope and provenance:** the review of `6489386956c0..6bdcde61f` found that +trusted recovery incorrectly rejected an application's reused PID. The fix is +commit `8b2fb7e67dc5864180c7863ac94fc3c0c3c9d6e3`, still stacked on the +pinned #4492 dependency `6489386956c0485cb91b478a8f4adc4828f4178b`. +No rebase, dependency-branch write, push, or PR occurred. + +**Implemented, not yet runtime-qualified:** recovery first queries native +creation time with `PROCESS_QUERY_LIMITED_INFORMATION`. A different time +settles the recorded incarnation as `exited`, without application control, +termination rights, or signals to its live replacement. An unreadable start, +including access denied, remains fenced. A matching start still requires +complete identity and exclusive control before exact cleanup. Inspection and +attach continue rejecting stale pins. This supersedes the earlier ledger's +recovery-specific stale-start refusal; that earlier native run does **not** +qualify the corrected recovery semantics. + +**Observed validation:** + +- **Passed:** package `yarn format`, `yarn lint`, and `yarn build`. +- **Passed:** `yarn workspace @fluentui-react-native/desktop-driver test --runInBand` + with native opt-in unset: **1,234 passed, 93 skipped**. Native skips are + not native qualification. Expanded arguments/results are retained under + `artifacts/desktop/f10w/review-20261112/driver-tests.log` and + `driver-results.json`. +- **Passed:** compile-only Windows x64 Debug `build-driver --platform windows +--configuration debug --cache-root artifacts/desktop/f10w/n --force +--reporter json`, through the declared package CLI and machine lock. It + waited for W3's campaign, acquired the lock at `2026-11-12T16:50:25Z`, + launched/registered no application, and exited zero. + Helper source digest: + `3558e8597f6cfe1ee3f4e38dca2137710b80d193a5c30bf946d54065bf8bc153`; + executable SHA-256: + `97f0072ce22449bc5c87c05de259d3e4c46eca666e6135977432b30fa16053b2`. + Full invocation/artifact receipt: ignored `helper-build.log` in that evidence + directory. Compilation is not native runtime or renderer qualification. + +**Not run on this corrected source:** native self-test, the expanded fixture +recovery case, and Windows Fabric/Win32 Paper consumer recovery. The opt-in +fixture now expects a stale recorded start to return `exited` while the live +controlled replacement survives; matching-start session/executable/AUMID +mismatches and another controller must still fail closed. Consumer Native RED +against the retained pre-fix helper and provider/consumer GREEN against the +rebuilt helper await the next granted W2 campaign, under the physical-session +gate where applicable. +No W2 campaign or session kind is claimed for this compile/portable phase. +Final clean-cache `buildci` and publication remain **Not run**. Part of #4447 +and #4435. + +**Observed closeout:** root `yarn oxfmt --check` (**3,163 files**), +`yarn check-agent-instructions`, `yarn change:check`, `git diff --check`, and +link checks for every edited Markdown reference and both ledgers **Passed**. +The first concurrently requested audit saw only the portable formatter's two +Node processes; the idle rerun was clean: +`FURN-AUDIT: no FURN desktop processes found`. No W2 desktop campaign was +started. The old and rebuilt helper files both match their recorded SHA-256; +the old binary remains available for the expected-failing consumer run. + +## Windows Fabric and Win32 Paper — default-environment buildci — 2026-11-12 UTC + +**Observed:** the root `yarn clean-lage-cache` followed by `yarn lage buildci` +passed at `68d63ccfbf008b963e59772f57aca8ac8f9ff05f`: **178 successful +tasks, 0 failed/skipped**, in 2m 32.26s. Both commands ran under the W2 machine +lock without `-RequirePhysical`, with default TEMP and concurrency and no +environment or repository-guidance adjustments. + +The first attempt at that same revision failed the Storybook server-integration +child-exit assertion and the inline-worker aggregate's 120s timeout. +Before considering a fix, the same full default-environment gate was run at +pinned base `6489386956c0485cb91b478a8f4adc4828f4178b`: **Passed**, 178/0. +The unchanged W2 rerun then passed. No source fix, timeout increase, dependency +install, or concurrency override was made; baseline failure was not reproduced. + +Ignored receipts are under `artifacts/desktop/f10w/review-20261112/`: +`buildci-default.log`, `buildci-default-base.log`, +`buildci-default-rerun.log`, `buildci-default-summary.json`, and +`buildci-default-final-audit.log`. The final audit **Passed**: +`FURN-AUDIT: no FURN desktop processes found`. +No app or native contract was launched. The desktop remained disconnected; +both endpoints' corrected native recovery qualification remains **Not run** +pending an active/unlocked session and a separate grant. No push occurred. +Part of #4447 and #4435. + +## Windows Fabric and Win32 Paper — recovery native re-verification — 2026-11-12 UTC + +**Observed source:** native implementation +`dbaf558939db31b4a3935ad68b1eb20d25d90d3e`, stacked on pinned #4492 +`6489386956c0485cb91b478a8f4adc4828f4178b`. The helper and public host API +were unchanged in this phase. The harness consumer needed a test-only fix: +`recoverApplication` checks native `{ state: 'exited' }` internally and resolves +`Promise`, so the consumer must expect `undefined`. That one-line +assertion correction is committed with this evidence and needs review before +publication; no production, protocol or F11 file changed. + +**Observed granted native session:** active/unlocked **remote** session 1, +input desktop `Default`, with eligible before/after checks for every native +wrapper. Both W2 campaigns used the machine lock and `-RequirePhysical`; no +exit 75 or session change occurred. The final consumer wrapper ran +`21:54:54Z–21:57:02Z`. Fabric registration was checked before each phase and +remained the `furn-81` Debug/x64 build at `bdeb0b291`, with no relevant native +source difference and executable SHA-256 +`df9cb3559843fcfb0158ee4ab5edbae78890c4e231ebd895cbb636c24b18cd45`. +The exact AUMID remained +`40411fc5-8e92-4d46-b68d-b62df44b1366_syyqehzwccsyy!App`. +REX Paper used the canonical worktree image `0.81.1-6828073195`, exact launch +arguments, a fresh Win32 bundle and owned worktree Metro. A Win32 native-app +build remains **Unsupported**. + +**Observed native RED/GREEN:** the retained pre-review helper's source was +`ea76e931dc4cdebc87e0621a7d730f81faabce5ef902063a0512cd7c2b100f2d`, +executable SHA-256 +`863cc2dbfc8f5792d22c8606376d226affbf8655fe4ded62ba78c23a29c516c7`. +It produced **4 expected failures / 0 passed / 7 filtered skips** across the +two endpoints' stale-start and crashed-recoverer cases: +`recoverApplication` rejected with `identity-start-mismatch`. + +The selected review-fixed consumer helper had source +`3558e8597f6cfe1ee3f4e38dca2137710b80d193a5c30bf946d54065bf8bc153` +and executable SHA-256 +`97f0072ce22449bc5c87c05de259d3e4c46eca666e6135977432b30fa16053b2`. +Its first consumer run had **9 passed / 2 failed**, solely because the test +expected the wire object rather than the host's void resolution. +After correcting only that assertion, all **11 consumers passed / 0 failed / +0 skipped**. Native recovery settles a different recorded start without +control/termination access or replacement signals; matching-start +session/executable/AUMID mismatches still fail closed. Unreadable native starts +remain fenced; no real protected-process access-denial case was induced here. + +**Exact provider command:** from the driver package, inside the granted +physical-gated machine lock, with `FURN_NATIVE_DRIVER_TEST=1`, +`FURN_NATIVE_PHYSICAL_INPUT_TEST=1`, Debug configuration and worktree-local +scratch/cache. `$W2` denotes the assigned worktree; `$E` is its ignored +`artifacts\desktop\f10w\review-native-20261112` directory. +The direct-helper override is unset for the cache-resolution contract. + +```powershell +yarn test --runInBand ` + --cacheDirectory "$W2\artifacts\desktop\f10w\jest-driver" ` + --runTestsByPath src\native\nativeDriver.test.ts src\native\windows\applicationOwnership.contract.test.ts ` + --testNamePattern 'Windows exact application ownership|builds, reuses, resolves, and handshakes with the Windows helper' ` + --json --outputFile "$E\driver-green-results.json" +``` + +| Provider/self-test case | Outcome | +| ------------------------------------------------------------------------------------------------ | --------------- | +| Build, reuse, resolve, handshake and Windows helper self-test | **Passed** | +| Noncontrolling inspection and exclusion of another independent helper | **Passed** | +| Reused recorded start settles without signalling the live replacement; other mismatches rejected | **Passed** | +| Multiple matching process/window candidates rejected | **Passed** | +| Five contained exact-identity launch/close cycles | **Passed**, 5/5 | +| Independent applications serialize through the physical input mutex | **Passed** | + +Final provider result: **6 passed / 0 failed / 10 unrelated filtered skips**. +The first invocation mistakenly set a direct-helper path, so the five ownership +cases passed but the isolated cache test resolved that explicit artifact +instead of its temporary build. Removing the conflicting invocation override, +without changing source, produced the full passing result. Both receipts are +preserved; this was not a resolver fix or an additional production defect. + +**Observed real consumer results:** both endpoints passed five distinct +PID/start incarnations, exact independent-service exclusion, native mismatch +refusals, crashed-recoverer barrier retirement, crashed-owner journal +reconciliation and three concurrent pairs. Fabric consistently reported +`uncontained` and exact survivor cleanup; REX reported `job-object` and +reconciled its job drain. The cross-endpoint physical-mutex case passed with +cancellation, release and transfer. All recorded application sessions were +`windows-session:1`. The crash fixtures verified each owned child's native +identity before literal-PID termination; the new dead-recoverer PIDs were +**54440** and **55808**. Live replacements survived stale-record retirement. +The harness's matching section records all 11 cases and exact consumer +commands. + +Ignored evidence root: `artifacts\desktop\f10w\review-native-20261112`. +`driver-green-results.json`, `red-consumer-results.json`, +`green-initial-consumer-results.json`, `green-consumer-results.json`, +`green-consumer-evidence.json`, `consumer-assertion-fix.diff` and +`qualification-summary-final.json` retain the failures, corrected results, +digests and actual test-source provenance. Final consumer evidence: +`windows-application-ownership-3BLzLV`; lifecycle report +`32f3d6be-c4b0-4792-aeb6-a5d7df165fc0` has no test counts. + +**Observed portable/aggregate closeout:** harness format/lint/build and +**453** portable tests **Passed** (**28** native skips). Existing driver +`NativeDesktopHost.applicationIdentity` contracts **Passed**, **31/31**. +Locked default-environment `yarn clean-lage-cache` then `yarn lage buildci` +initially **Failed** the reported intermittent Storybook `serverIntegration` +readiness assertion, `A stopping command cannot publish readiness` +(`176` successful tasks, one failed). The cause remains unproven. +After the fail-fast test children settled and a clean audit, the sole unchanged +full rerun **Passed**, **178 successful / 0 failed / 0 skipped**, 2m 38.65s. +No TEMP, concurrency, timeout, dependency or guidance workaround was applied. +Both W2 campaigns ended and the final audit **Passed**: +`FURN-AUDIT: no FURN desktop processes found`. + +**Publication/remaining limits:** no push or PR-body update occurred because +the test-only correction needs review. Initial PR #4494 CI at `dbaf558939` +was observed green: run `43282149276`, all **19/19** checks successful. +Windows job `131405956387` selected the ownership provider suite +(`PASS applicationOwnership.contract.test.ts`; **4** nonphysical ownership +cases selected, physical case gated off; full driver **1,281 passed / +46 skipped**). The Win32 jobs did not select that ownership step, and the +actual-endpoint consumer opt-in was absent: **0/11** consumers hosted. +These are historical hosted counts, not new CI for the local test correction +or substitutes for this physical campaign. F11 claim naming is deferred and +untouched. Actual kernel PID recycling and shared-PID activation were +**Not induced**; all concurrent winners were distinct. Arbitrary uncontained +descendants and mixed-graph recovery remain unqualified. The existing +`windows-exact-application-ownership.md` patch changeset remains applicable. +Part of #4447 and #4435. + +**Observed reference checks:** Markdown links in both edited ledgers +**Passed** (6 driver links, 15 harness links). Root `yarn oxfmt --check` +(3,163 files), `yarn check-agent-instructions`, `yarn change:check`, and +`git diff --check` **Passed**. Their receipts are in the same ignored evidence +directory. + ## F15 non-AX macOS supporting evidence — 2026-11-12 Source `505a2187b0a2451aed7081c9260b343d55742f93`; macOS 27.2.1 diff --git a/packages/agentic/desktop-driver/references/native-helpers.md b/packages/agentic/desktop-driver/references/native-helpers.md index 8c71e54b02..d91630efe6 100644 --- a/packages/agentic/desktop-driver/references/native-helpers.md +++ b/packages/agentic/desktop-driver/references/native-helpers.md @@ -208,6 +208,18 @@ output. ### Shutdown and cancellation +Windows and Win32 application leases retain native 100 ns creation time, +canonical image, session, and AUMID when packaged. Read-only +`inspectApplication` never takes controlling application ownership or changes +session window records. Exact PID/start application exclusion is independent +of the physical-input mutex. Trusted survivor recovery queries creation time +with limited rights first: a reused PID proves the recorded incarnation exited, +without signalling or taking control of its replacement. An unreadable start +remains fenced. A matching start still requires the complete identity and +exclusive application control before cleanup. +See the [Windows provider](../native/windows/README.md#application-identity) +and [wire contract](../native/PROTOCOL.md#windows-application-ownership). + Every failure takes one terminal path: Node stops the helper, recovers input the helper may have left depressed, and then rejects each pending request once. This holds for a crash, a write failure, a malformed frame, a diff --git a/packages/agentic/desktop-driver/src/host/types.ts b/packages/agentic/desktop-driver/src/host/types.ts index b77e74fdbc..fbff70c93e 100644 --- a/packages/agentic/desktop-driver/src/host/types.ts +++ b/packages/agentic/desktop-driver/src/host/types.ts @@ -90,8 +90,11 @@ export type ApplicationLease = { ownership: 'attached' | 'launched'; processId?: number; processStartedAt?: string; - /** LaunchServices causality is not direct child-process containment. */ - launchKind?: 'launch-services' | 'direct-child'; + executablePath?: string; + interactiveSession?: string; + aumid?: string; + /** OS activation is not direct child-process containment. */ + launchKind?: 'launch-services' | 'direct-child' | 'packaged-activation'; }; export type DesktopWindow = { diff --git a/packages/agentic/desktop-driver/src/hosts/native/NativeDesktopHost.applicationIdentity.test.ts b/packages/agentic/desktop-driver/src/hosts/native/NativeDesktopHost.applicationIdentity.test.ts index 5a25724d98..732b2c8046 100644 --- a/packages/agentic/desktop-driver/src/hosts/native/NativeDesktopHost.applicationIdentity.test.ts +++ b/packages/agentic/desktop-driver/src/hosts/native/NativeDesktopHost.applicationIdentity.test.ts @@ -2,6 +2,7 @@ import type { DesktopTarget } from '../../host/types'; import type { NativeDriverArtifact } from '../../native/types'; import { scriptNativeHelper } from '../../testing/scriptedNativeHelper'; import { NativeDesktopHost } from './NativeDesktopHost'; +import { encodeJsonFrame } from './framing'; const mockSpawn = jest.fn(); jest.mock('node:child_process', () => ({ spawn: (...args: unknown[]) => mockSpawn(...args) })); @@ -25,6 +26,18 @@ const artifact: NativeDriverArtifact = { wireProtocol: { major: 1, minor: 0 }, }; const identity = { processId: 123, processStartedAt: '2026-11-12T00:00:00.123456Z' }; +const windowsIdentity = { + processId: 456, + processStartedAt: '2026-11-12T00:00:00.1234567Z', + executablePath: 'C:\\apps\\ReactTest.exe', + interactiveSession: 'windows-session:2', +}; +const windowsArtifact: NativeDriverArtifact = { + ...artifact, + architecture: 'x64', + endpoints: ['windows', 'win32'], + provider: 'windows', +}; const application = { leaseNonce: 'workload-nonce', leasePath: 'workload-lease.json', windowTitle: 'Storybook' }; const registration = { endpoint: 'macos', id: 'storybook', platformName: 'macos' } satisfies Omit; @@ -112,6 +125,57 @@ test.each([ } }); +test.each([ + { ...windowsIdentity, processId: 0 }, + { ...windowsIdentity, executablePath: 'ReactTest.exe' }, + { ...windowsIdentity, interactiveSession: undefined }, + { ...windowsIdentity, processStartedAt: '2026-11-12T00:00:00.123456Z' }, + { ...windowsIdentity, aumid: '' }, + { ...windowsIdentity, unexpected: 'field' }, +])('rejects invalid recovery authority before native dispatch %j', async (identity) => { + const received = windowsHelper({ state: 'exited' }); + const native = new NativeDesktopHost({ application: {}, artifact: windowsArtifact, endpoint: 'windows' }); + try { + await expect(native.recoverApplication(identity)).rejects.toMatchObject({ + data: { nativeCode: 'invalid-application-identity' }, + }); + expect(received).toHaveLength(0); + expect(mockSpawn).not.toHaveBeenCalled(); + } finally { + await native.dispose(); + } +}); + +test('exact recovery sends only the recorded incarnation and requires a native exit verdict', async () => { + const received = windowsHelper({ state: 'exited' }); + const native = new NativeDesktopHost({ application: {}, artifact: windowsArtifact, endpoint: 'win32' }); + try { + await expect(native.recoverApplication(windowsIdentity)).resolves.toBeUndefined(); + expect(received).toHaveLength(1); + expect(received[0]).toMatchObject({ + command: 'recoverApplication', + params: { endpoint: 'win32', identity: windowsIdentity }, + }); + } finally { + await native.dispose(); + } +}); + +test.each([null, {}, { state: 'unknown' }, { state: 'exited', unverified: true }])( + 'an incomplete recovery verdict stays fenced %j', + async (result) => { + windowsHelper(result); + const native = new NativeDesktopHost({ application: {}, artifact: windowsArtifact, endpoint: 'windows' }); + try { + await expect(native.recoverApplication(windowsIdentity)).rejects.toMatchObject({ + data: { nativeCode: 'application-recovery-unsettled' }, + }); + } 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' }), @@ -125,14 +189,66 @@ test('does not silently replace inspection with a controlling attach on an old h } }); -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(); +function windowsHelper(result: unknown) { + return scriptNativeHelper( + mockSpawn, + (request, send) => send({ id: request.id, result, type: 'response' }), + ['inspectApplication', 'recoverApplication'], + { + greeting: () => + encodeJsonFrame({ + architecture: 'x64', + buildId: 'build', + features: ['inspectApplication', 'recoverApplication'], + protocol: { major: 1, minor: 0 }, + provider: 'windows', + sourceDigest: 'source', + type: 'hello', + }), + }, + ); +} + +test.each(['windows', 'win32'] as const)('inspects exact %s process, executable and session without attaching', async (endpoint) => { + const received = windowsHelper(windowsIdentity); + const native = new NativeDesktopHost({ application, artifact: windowsArtifact, endpoint }); + try { + await expect(native.inspectApplication({ ...registration, endpoint, host: native })).resolves.toEqual(windowsIdentity); + expect(received.map(({ command }) => command)).toEqual(['inspectApplication']); + } finally { + await native.dispose(); + } +}); + +test.each([{ ...windowsIdentity, aumid: 'package_family!App' }, windowsIdentity])( + 'preserves verified packaged or unpackaged identity evidence %j', + async (result) => { + windowsHelper(result); + const native = new NativeDesktopHost({ application, artifact: windowsArtifact, endpoint: 'windows' }); + try { + await expect(native.inspectApplication({ ...registration, endpoint: 'windows', host: native })).resolves.toEqual(result); + } finally { + await native.dispose(); + } + }, +); + +test.each([ + { ...windowsIdentity, executablePath: 'ReactTest.exe' }, + { ...windowsIdentity, executablePath: 'C:\\apps\\bad\u0000.exe' }, + { ...windowsIdentity, interactiveSession: undefined }, + { ...windowsIdentity, interactiveSession: 'macos-session:2' }, + { ...windowsIdentity, processStartedAt: '2026-11-12T00:00:00.123456Z' }, + { ...windowsIdentity, aumid: '' }, + { ...windowsIdentity, id: 'controlling-lease' }, +])('rejects incomplete or ambiguous Windows inspection evidence %j', async (result) => { + windowsHelper(result); + const native = new NativeDesktopHost({ application, artifact: windowsArtifact, endpoint: 'windows' }); + try { + await expect(native.inspectApplication({ ...registration, endpoint: 'windows', host: native })).rejects.toMatchObject({ + data: { nativeCode: 'invalid-application-identity' }, + }); + } finally { + await native.dispose(); + } }); diff --git a/packages/agentic/desktop-driver/src/hosts/native/NativeDesktopHost.ts b/packages/agentic/desktop-driver/src/hosts/native/NativeDesktopHost.ts index 1be3b78afd..67a97af040 100644 --- a/packages/agentic/desktop-driver/src/hosts/native/NativeDesktopHost.ts +++ b/packages/agentic/desktop-driver/src/hosts/native/NativeDesktopHost.ts @@ -1,4 +1,5 @@ import { spawn } from 'node:child_process'; +import path from 'node:path'; import type { ApplicationLease, @@ -61,6 +62,47 @@ const defaultCancellationTimeoutMs: Readonly | null; + const windows = endpoint !== 'macos'; + if ( + !result || + typeof result !== 'object' || + Array.isArray(result) || + Object.keys(result).some( + (key) => + !( + windows ? ['processId', 'processStartedAt', 'executablePath', 'interactiveSession', 'aumid'] : ['processId', 'processStartedAt'] + ).includes(key), + ) || + typeof result.processId !== 'number' || + !Number.isInteger(result.processId) || + result.processId < 1 || + result.processId > 0x7fffffff || + typeof result.processStartedAt !== 'string' || + !( + windows + ? /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{7}Z$/ + : /^[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)) || + (windows && + (typeof result.executablePath !== 'string' || + !path.win32.isAbsolute(result.executablePath) || + result.executablePath.length > 4096 || + /\p{Cc}/u.test(result.executablePath) || + typeof result.interactiveSession !== 'string' || + !/^windows-session:[0-9]{1,10}$/.test(result.interactiveSession) || + (result.aumid !== undefined && + (typeof result.aumid !== 'string' || result.aumid.length < 1 || result.aumid.length > 4096 || /\p{Cc}/u.test(result.aumid))))) + ) { + throw new HostWebDriverError('unknown error', 'The native application identity is invalid.', { + nativeCode: 'invalid-application-identity', + }); + } + return Object.freeze({ ...result } as NativeApplicationIdentity); +} + export class NativeDesktopHost implements DesktopHost { readonly endpoint: DesktopEndpoint; @@ -121,27 +163,25 @@ 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. */ + /** 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 requireApplicationIdentity(await this.request('inspectApplication', this.targetParams(target), signal), this.endpoint); + } + + /** Trusted workload recovery only; no WebDriver route exposes termination of a recorded Windows survivor. */ + async recoverApplication(identity: NativeApplicationIdentity, signal?: AbortSignal): Promise { + if (this.endpoint === 'macos') throw new HostUnsupportedError('Exact survivor recovery is Windows-only.'); + const verified = requireApplicationIdentity(identity, this.endpoint); + const result = await this.request<{ state?: unknown } | null>( + 'recoverApplication', + { identity: verified, endpoint: this.endpoint }, + signal, + ); + if (!result || typeof result !== 'object' || Object.keys(result).length !== 1 || result.state !== 'exited') { + throw new HostWebDriverError('unknown error', 'The native helper did not prove exact application exit.', { + nativeCode: 'application-recovery-unsettled', }); } - return Object.freeze({ processId: result.processId, processStartedAt: result.processStartedAt }); } async closeApplication(lease: ApplicationLease, signal?: AbortSignal): Promise { diff --git a/packages/agentic/desktop-driver/src/native/types.ts b/packages/agentic/desktop-driver/src/native/types.ts index 74d1f8609d..b7b41021fd 100644 --- a/packages/agentic/desktop-driver/src/native/types.ts +++ b/packages/agentic/desktop-driver/src/native/types.ts @@ -56,7 +56,14 @@ export type NativeDriverResolveOptions = NativeDriverBuildOptions & { }; /** Native-verified process identity without a controlling application lease. */ -export type NativeApplicationIdentity = Readonly<{ processId: number; processStartedAt: string }>; +export type NativeApplicationIdentity = Readonly<{ + processId: number; + processStartedAt: string; + /** Required Windows evidence; the executable also distinguishes each Win32 REX host incarnation. */ + executablePath?: string; + interactiveSession?: string; + aumid?: string; +}>; export type NativeDesktopApplicationDescriptor = { arguments?: readonly string[]; @@ -65,7 +72,7 @@ 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. */ + /** Exact native attach identity. Never grants launch/termination authority and cannot be combined with a legacy lease. */ processIdentity?: NativeApplicationIdentity; windowTitle?: string; workingDirectory?: string; diff --git a/packages/agentic/desktop-driver/src/native/windows/applicationOwnership.contract.test.ts b/packages/agentic/desktop-driver/src/native/windows/applicationOwnership.contract.test.ts new file mode 100644 index 0000000000..000564f131 --- /dev/null +++ b/packages/agentic/desktop-driver/src/native/windows/applicationOwnership.contract.test.ts @@ -0,0 +1,267 @@ +import { spawn, spawnSync } from 'node:child_process'; +import type { ChildProcess } from 'node:child_process'; +import { once } from 'node:events'; + +import { describeWindowsService } from '../../authority/windowsServiceIdentity'; +import type { ApplicationLease } from '../../host/types'; +import { NativeHostProcess } from '../../hosts/native/NativeHostProcess'; +import type { NativeApplicationIdentity } from '../types'; +import { fixtureExecutable, helperArtifact, required, waitFor, windowsContractTest } from './win32FixtureSupport'; + +jest.setTimeout(240_000); + +const windowsTest = windowsContractTest(); +const physicalTest = process.env.FURN_NATIVE_PHYSICAL_INPUT_TEST === '1' ? windowsContractTest('keyboard') : test.skip; +const externalIdentities = new WeakMap>>(); + +function oneTickMismatch(startedAt: string): string { + const fraction = Number(startedAt.slice(-8, -1)); + const changed = fraction === 9_999_999 ? fraction - 1 : fraction + 1; + return `${startedAt.slice(0, -8)}${String(changed).padStart(7, '0')}Z`; +} + +async function cleanupAll(cleanups: (() => Promise)[]): Promise { + const failures: unknown[] = []; + for (const cleanup of cleanups) { + try { + await cleanup(); + } catch (error) { + failures.push(error); + } + } + if (failures.length) throw new AggregateError(failures, 'Native ownership fixture cleanup failed.'); +} + +async function external(title: string): Promise { + const executable = await fixtureExecutable(); + const artifact = await helperArtifact(); + const child = spawn(executable, ['--title', title, '--offscreen', '1', '--lifetime-ms', '90000'], { + stdio: 'ignore', + windowsHide: true, + }); + await once(child, 'spawn'); + try { + const identity = await describeWindowsService({ artifact, pid: required(child.pid, 'fixture PID') }); + expect(identity.executablePath.toLowerCase()).toBe(executable.toLowerCase()); + externalIdentities.set(child, identity); + } catch (error) { + await waitFor( + 'the unverified fixture lifetime to expire', + async () => (child.exitCode !== null || child.signalCode !== null ? true : undefined), + { timeoutMs: 100_000 }, + ); + throw error; + } + return child; +} + +async function closeExternal(child: ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + const identity = required(externalIdentities.get(child), 'the externally launched fixture identity'); + expect(Number.isSafeInteger(identity.pid) && identity.pid > 0).toBe(true); + expect(await describeWindowsService({ artifact: await helperArtifact(), pid: identity.pid })).toEqual(identity); + const exited = once(child, 'exit'); + const result = spawnSync( + 'pwsh', + ['-NoProfile', '-NonInteractive', '-Command', 'Stop-Process', '-Id', String(identity.pid), '-ErrorAction', 'Stop'], + { encoding: 'utf8', timeout: 30_000, windowsHide: true }, + ); + if (result.error || result.status !== 0) throw result.error ?? new Error('The identity-verified fixture stop failed.'); + await exited; +} + +async function inspect(host: NativeHostProcess, title: string) { + return ( + await host.request('inspectApplication', { + application: { executablePath: await fixtureExecutable(), windowTitle: title }, + endpoint: 'win32', + }) + ).result; +} + +describe('Windows exact application ownership', () => { + windowsTest('inspects without taking control and excludes a second independent helper until release', async () => { + const title = `furn-ownership-external-${process.pid}-${Date.now()}`; + const child = await external(title); + const params = { application: { executablePath: await fixtureExecutable(), windowTitle: title }, endpoint: 'win32' }; + const cleanups = [() => closeExternal(child)]; + try { + const first = await NativeHostProcess.start({ artifact: await helperArtifact() }); + cleanups.unshift(() => first.dispose()); + const second = await NativeHostProcess.start({ artifact: await helperArtifact() }); + cleanups.unshift(() => second.dispose()); + const observed = await waitFor('the external fixture identity', async () => inspect(first, title).catch(() => undefined), { + intervalMs: 100, + timeoutMs: 20_000, + }); + const native = await describeWindowsService({ artifact: await helperArtifact(), pid: required(child.pid, 'fixture PID') }); + expect(observed).toEqual({ + processId: child.pid, + processStartedAt: native.startedAt, + executablePath: native.executablePath, + interactiveSession: native.interactiveSession, + }); + const { result: lease } = await first.request('attach', params); + expect(lease).toMatchObject({ ...observed, ownership: 'attached', containment: 'external' }); + const before = (await first.request('windows', { lease })).result; + expect(await inspect(second, title)).toEqual(observed); + expect((await first.request('windows', { lease })).result).toEqual(before); + await expect(second.request('attach', params)).rejects.toMatchObject({ + code: 'attach-failed', + data: { reason: 'application-lock-busy' }, + }); + await first.request('closeApplication', { lease }); + const borrowed = (await second.request('attach', params)).result; + await second.request('closeApplication', { lease: borrowed }); + expect(child.exitCode).toBeNull(); + } finally { + await cleanupAll(cleanups); + } + }); + + windowsTest('retires a reused recorded incarnation without signalling its live replacement and rejects other mismatches', async () => { + const title = `furn-ownership-stale-${process.pid}-${Date.now()}`; + const host = await NativeHostProcess.start({ artifact: await helperArtifact() }); + const application = { + executablePath: await fixtureExecutable(), + windowTitle: title, + arguments: ['--title', title, '--offscreen', '1'], + }; + let lease: ApplicationLease | undefined; + try { + lease = (await host.request('launch', { application, endpoint: 'win32', launchTimeoutMs: 20_000 })).result; + const observed = await inspect(host, title); + const cases = [ + ['identity-start-mismatch', { ...observed, processStartedAt: oneTickMismatch(observed.processStartedAt) }], + ['identity-session-mismatch', { ...observed, interactiveSession: 'windows-session:999999' }], + ['identity-executable-mismatch', { ...observed, executablePath: 'C:\\not-the-fixture.exe' }], + ['identity-aumid-mismatch', { ...observed, aumid: 'foreign_package!App' }], + ] as const; + for (const [reason, identity] of cases) { + await expect( + host.request('inspectApplication', { application: { ...application, processIdentity: identity }, endpoint: 'win32' }), + ).rejects.toMatchObject({ data: { reason } }); + if (reason === 'identity-start-mismatch') { + await expect(host.request('recoverApplication', { identity, endpoint: 'win32' })).resolves.toMatchObject({ + result: { state: 'exited' }, + }); + } else { + await expect(host.request('recoverApplication', { identity, endpoint: 'win32' })).rejects.toMatchObject({ data: { reason } }); + } + } + expect(await inspect(host, title)).toEqual(observed); + await expect(host.request('recoverApplication', { identity: observed, endpoint: 'win32' })).rejects.toMatchObject({ + data: { reason: 'application-lock-busy' }, + }); + } finally { + await cleanupAll([ + async () => { + if (lease) await host.request('closeApplication', { lease }); + }, + () => host.dispose(), + ]); + } + }); + + windowsTest('rejects two matching process/window candidates rather than selecting the first', async () => { + const title = `furn-ownership-ambiguous-${process.pid}-${Date.now()}`; + const host = await NativeHostProcess.start({ artifact: await helperArtifact() }); + const cleanups = [() => host.dispose()]; + try { + const first = await external(title); + cleanups.push(() => closeExternal(first)); + await waitFor('the first external fixture', async () => inspect(host, title).catch(() => undefined), { + intervalMs: 100, + timeoutMs: 20_000, + }); + const second = await external(title); + cleanups.push(() => closeExternal(second)); + await waitFor( + 'two matching external windows', + async () => + host + .request('inspectApplication', { + application: { executablePath: await fixtureExecutable(), windowTitle: title }, + endpoint: 'win32', + }) + .then( + () => false, + (error: { code?: string; data?: { reason?: string } }) => + error.code === 'ambiguous-target' && error.data?.reason === 'application-identity-ambiguous', + ), + { intervalMs: 100, timeoutMs: 20_000 }, + ); + } finally { + await cleanupAll(cleanups); + } + }); + + windowsTest('records exact contained identity across five consecutive launch/close cycles without reuse', async () => { + const host = await NativeHostProcess.start({ artifact: await helperArtifact() }); + const incarnations = new Set(); + try { + for (let iteration = 0; iteration < 5; iteration++) { + const title = `furn-ownership-repeat-${process.pid}-${iteration}-${Date.now()}`; + const lease = ( + await host.request('launch', { + application: { + executablePath: await fixtureExecutable(), + windowTitle: title, + arguments: ['--title', title, '--offscreen', '1'], + }, + endpoint: 'win32', + launchTimeoutMs: 20_000, + }) + ).result; + expect(lease).toMatchObject({ + containment: 'job-object', + launchKind: 'direct-child', + interactiveSession: expect.stringMatching(/^windows-session:[0-9]+$/), + processStartedAt: expect.stringMatching(/\.[0-9]{7}Z$/), + }); + const observed = await inspect(host, title); + incarnations.add(JSON.stringify([observed.processId, observed.processStartedAt])); + await host.request('closeApplication', { lease }); + await host.request('recoverApplication', { identity: observed, endpoint: 'win32' }); + } + expect(incarnations.size).toBe(5); + } finally { + await host.dispose(); + } + }); + + physicalTest('serializes independent applications through the native input mutex until release', async () => { + const cleanups: (() => Promise)[] = []; + const actions = (down: boolean) => [ + { type: 'key', id: 'ownership-shift', actions: [{ type: down ? 'keyDown' : 'keyUp', value: '\uE008' }] }, + ]; + try { + const first = await NativeHostProcess.start({ artifact: await helperArtifact() }); + cleanups.unshift(() => first.dispose()); + const second = await NativeHostProcess.start({ artifact: await helperArtifact() }); + cleanups.unshift(() => second.dispose()); + for (const [index, host] of [first, second].entries()) { + const title = `furn-ownership-input-${process.pid}-${index}-${Date.now()}`; + await host.request('launch', { + application: { + executablePath: await fixtureExecutable(), + windowTitle: title, + arguments: ['--title', title, '--offscreen', '0'], + }, + launchTimeoutMs: 20_000, + endpoint: 'win32', + }); + } + await first.request('performActions', { actions: actions(true) }); + await expect(second.request('performActions', { actions: actions(true) }, AbortSignal.timeout(300))).rejects.toMatchObject({ + name: 'AbortError', + effect: 'none', + }); + await first.request('releaseActions'); + await second.request('performActions', { actions: actions(true) }); + await second.request('releaseActions'); + } finally { + await cleanupAll(cleanups); + } + }); +}); diff --git a/packages/agentic/desktop-test/README.md b/packages/agentic/desktop-test/README.md index 39f2c4c6f6..9ccf5ec432 100644 --- a/packages/agentic/desktop-test/README.md +++ b/packages/agentic/desktop-test/README.md @@ -66,11 +66,11 @@ when working on the package. Per-worker credentials are scrubbed before user hooks/specs; Node bootstrap and preloads remain trusted during the initial fork-environment window. - The generic harness supervises services, workers, the driver, and application - claim resources. Launch defaults to session-owned; macOS can opt into - supervisor-owned launch. External attach always preserves the app. Neutral - exact identity helpers live at `/application-ownership`; Storybook handoff and - lifecycle takeover remain transitional, and retained session launches remain - unsupported (see [lifecycle](references/lifecycle.md)). + claim resources. Launch defaults to session-owned; macOS, Windows and Win32 + can opt into supervisor-owned launch. External attach always preserves the + app. Neutral exact identity helpers live at `/application-ownership`; + Storybook handoff and lifecycle takeover remain transitional, and retained + session launches remain unsupported (see [lifecycle](references/lifecycle.md)). - Every run records structured results, command output, and evidence in its artifact directory. - Native helpers are explicitly built or provisioned; normal test runs use @@ -78,6 +78,10 @@ when working on the package. - Generic and Storybook native services share one factory with durable quarantine storage and exact application-exit probes. Restart does not clear uncertain effects; see [lifecycle and recovery](references/lifecycle.md). +- Windows native startup can reconcile acquired application resources from a + proven-exited owner using exact PID/start/executable/session/AUMID evidence. + Unknown resources, interrupted launches, and unrelated effects remain fenced; + this is not general stale-claim removal. - Owned Node services inherit a per-command parent lifeline. Cooperative disposers await initialization and settlement before withdrawing descriptors; Metro and command wrappers use their own SIGTERM handlers. Without a diff --git a/packages/agentic/desktop-test/references/configuration.md b/packages/agentic/desktop-test/references/configuration.md index b3ae796f0d..96f4ab3405 100644 --- a/packages/agentic/desktop-test/references/configuration.md +++ b/packages/agentic/desktop-test/references/configuration.md @@ -17,14 +17,15 @@ installed-app example. Targets select `macos`, `windows`, or `win32` endpoints and describe an `installed`, `react-native-test-app`, or `rex` host. Prefer an exact 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 +an executable path, AUMID, exact native process identity, 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 +`ownership: 'supervisor'` opts into one supervisor-owned 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. +incarnation. macOS, Windows and Win32 use their own native identity/cleanup +backend; qualification is recorded separately for each renderer in +[current status](current-status.md). For neutral macOS attach, set `ownership: 'external'` and an application `processIdentity: { processId, processStartedAt }` plus exact `windowTitle`. @@ -34,6 +35,14 @@ 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. +For Windows/Win32 exact attach, `processIdentity` additionally requires +`executablePath` and `interactiveSession: "windows-session:"`, plus `aumid` +when packaged. Preserve all seven fractional creation-time digits (100 ns). +Capture this identity from a native lease or read-only native inspection, not +from `Date`, a title, an AUMID alone, or a guessed REX PID. The canonical +`ReactTest.exe` image plus PID/start/session identifies one REX incarnation. +Exact identity is external attach evidence, never launch/termination authority. + `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 1e62eb8fd0..d3b61790a7 100644 --- a/packages/agentic/desktop-test/references/current-status.md +++ b/packages/agentic/desktop-test/references/current-status.md @@ -1923,6 +1923,557 @@ Evidence: `artifacts/desktop/f10m/retirement-followup-20261112/`, including `fixture-build.json`, `plain-aggregate.log`, `test-links.log`, `changed-markdown-links.log`, `cleanup.json` and `cleanup-final.json`. +## Windows Fabric and Win32 Paper — F10-W application ownership — 2026-11-12 UTC + +**Source and base decision:** implementation commit +`6e881e1a05fef735c1b9bacd7d123285f47269ff`, branch +`user/jasonvmo/desktop-windows-app-ownership`, **stacked on #4492** at +`6489386956c0485cb91b478a8f4adc4828f4178b`; draft PR base remains +`user/jasonvmo/desktop-macos-app-ownership`. The Windows side implements its +neutral application host/resources, claim schema 3, native OS owner identity, +exact process pins, inspection and launch settlement messages. A separate +claim schema or duplicate service factory was not introduced. This pins the +dependency; later #4492 movement is not authorization to rebase, merge or +push to its branch. + +Application resources can be named through the inherited neutral publication +seam without touching F11's CLI, service, agent connection, WDIO, session, +grant, client or MCP files. The base's existing `driverService.ts` changes +remain inherited only; W2 adds no edits to an F11-owned file. + +**Observed, test-first portable evidence:** new Windows identity/recovery +tests failed before implementation, then passed. Later red/green regressions +covered recovery barriers after claim removal, inconsistent containment, +unrecognized/invalid owner probes, preserving recovery plus barrier failures, +and portable service/reporting scopes without a prebuilt helper. The focused +Windows suite now has **31 passing tests** and supervisor suite **11**. +Existing neutral ownership tests also pass. Storybook identity tests pass +**7**, including Windows Fabric and Win32 Paper read-only resolution alongside +both macOS renderers; the epoch journal deliberately retains its existing +PID/start schema instead of taking another controlling attachment. + +Full portable results: + +| Package | Passed suites / tests | Skipped suites / tests | Interpretation | +| ----------------- | --------------------- | ---------------------- | -------------------------------------------------- | +| desktop-driver | 74 / 1,234 | 9 / 93 | Native opt-ins unset; not native qualification | +| desktop-test | 25 / 442 | 3 / 25 | Native/unsupported-platform opt-ins remain skipped | +| storybook-desktop | 23 / 347 | 1 / 3 | Portable integration; not renderer qualification | + +The first full harness attempt failed because the new eager Windows helper +resolution also affected portable scopes; focused failures reproduced it, +and the fix permits only `no-verified-prebuilt` to retain that existing +non-native behavior. Other verification failures still abort. A stale claim +cannot be recovered without a verified helper. The existing public boundary +fixture also needed the desktop-driver-mcp public entries built; no MCP source +was changed. Both failures were resolved before the full green harness rerun. + +Commands below ran from the worktree root; `$W2` denotes that root. +`TEMP`/`TMP` were scoped to its ignored `artifacts\desktop\f10w\scratch`; +`FURN_NATIVE_DRIVER_TEST` was unset. + +| Command | Observed result | +| --------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | +| `yarn workspace @fluentui-react-native/desktop-test format` | Passed | +| `yarn workspace @fluentui-react-native/desktop-test lint` | Passed | +| `yarn workspace @fluentui-react-native/desktop-test build` | Passed | +| `yarn workspace @fluentui-react-native/desktop-test test --runInBand --cacheDirectory "$W2\artifacts\desktop\f10w\jest-harness"` | Passed 442; skipped 25 | +| `yarn workspace @fluentui-react-native/storybook-desktop format` | Passed | +| `yarn workspace @fluentui-react-native/storybook-desktop lint` | Passed | +| `yarn workspace @fluentui-react-native/storybook-desktop build` | Passed | +| `yarn workspace @fluentui-react-native/storybook-desktop test --runInBand --cacheDirectory "$W2\artifacts\desktop\f10w\jest-storybook"` | Passed 347; skipped 3 | +| `yarn workspace @fluentui-react-native/desktop-driver-mcp build` | Passed; existing boundary fixture | +| `yarn check-agent-instructions` | Passed | +| `yarn oxfmt --check` | Passed, 3,163 files | + +Ignored evidence run: `artifacts/desktop/f10w/portable-20261112/`. +Logs include `harness-test-initial.log`, final `harness-test.log`, +`driver-test.log`, `storybook-test.log`, each owning build/lint log, and +`changed-markdown-links.log`. Per-file link checks passed for driver README, +native protocol, Windows provider README, driver contributing/native-helpers, +harness README/configuration/lifecycle/native-qualification, Storybook +contributing, and the three-package patch changeset. The root formatter +intentionally excludes `.changeset/*.md`. + +**Observed, helper build only:** the locked Debug/x64 `buildWindowsDriver` +compile with build ID `f10w-compile-02` passed, 0 warnings/errors. Its seven +changed C++ files match the compiled snapshot. Native build evidence is +`artifacts/desktop/f10w/b2/build.log`. No application, fixture, native +self-test or physical-input case has run, and session kind is **not sampled**. +The pre-campaign audit reported +`FURN-AUDIT: no FURN desktop processes found`; no W2 campaign was started. + +**Implemented, still requiring native proof:** Windows/Win32 supervisor +ownership, exact external pins and acquired resource evidence now include +canonical executable, native seven-digit creation time, session, and packaged +AUMID. The native supervisor can reconcile an acquired exact app only after +the old owner is proven exited/reused, before its replacement claim/listener. +It serializes recovery, validates file identity/content and resource/effect +consistency, durably records partial progress, and preserves external apps. +Unknown services/resources, pending/unleased launches, mixed service graphs, +interrupted recovery, unverified owners, and unrelated effects remain fenced. +Custom hosts/delegated claims acquire no automatic native termination authority. +There is no general stale-lock remover, quarantine reset, or new protected +agent recovery route. Mixed Storybook graph and arbitrary uncontained-descendant +recovery are not qualified by this bounded application-only path. + +| Native gate | Windows Fabric packaged | Win32 Paper REX | Required evidence / limit | +| ------------------------------------------------------------ | ----------------------- | --------------- | --------------------------------------------------------------------------------- | +| Exact application/native owner identity | Not run | Not run | AUMID or canonical REX/executable, PID/start/session from real native evidence | +| PID-reuse/mismatch and ambiguity rejection | Not run | Not run | Real mismatch/fixture cases; actual OS PID reuse only if inducible | +| Containment or explicit packaged fencing | Not run | Not run | Packaged stays `uncontained`; REX must report Job Object and drain on helper exit | +| Cross-service application exclusion and physical input mutex | Not run | Not run | Independent helpers, physical opt-in and connected/unlocked session gate | +| Acquired survivor journal and next-run recovery | Not run | Not run | Literal-PID, identity-verified helper/owner fault injection | +| Repeated-run reliability | Not run | Not run | Five launches/teardowns and no old exact incarnation or claim leak | + +The opt-in consumer command is +`$env:FURN_NATIVE_DRIVER_TEST='1'; $env:FURN_NATIVE_APPLICATION_OWNERSHIP_TEST='1'; $env:FURN_NATIVE_PHYSICAL_INPUT_TEST='1'; yarn workspace @fluentui-react-native/desktop-test test --runInBand windowsApplicationOwnership.contract`. +It requires explicitly resolved Windows AUMID and REX JSON descriptors and a +verified prebuilt helper, under the granted exclusive physical desktop +campaign. Each endpoint gets five launch/teardown iterations and two borrowed +sessions per launch, stale identity checks, cross-service exclusion, and +journal recovery. Three paired packaged activations also check that the +losing launcher does not clean up the winning service's app. +See `references/native-qualification.md`, “Neutral Windows application +ownership”. No skips above establish these gates. The final clean-cache +`yarn lage buildci`, independent review, push, draft PR and hosted CI remain +not run at this preparation checkpoint. Part of #4447 and #4435. + +Preparation closeout pinned to the implementation SHA above: + +| Post-commit command | Result | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | +| `yarn workspace @fluentui-react-native/desktop-test test --runInBand windowsOwnership supervisor applicationOwnership.test ownership.test --cacheDirectory "$W2\artifacts\desktop\f10w\jest-harness"` | Passed 71 tests | +| `yarn workspace @fluentui-react-native/storybook-desktop test --runInBand applicationIdentity --cacheDirectory "$W2\artifacts\desktop\f10w\jest-storybook"` | Passed 7 tests | +| `yarn change:check` | Passed | +| `yarn lage test-links --verbose --grouped` | Passed 90 tasks; 0 failed/skipped | +| `yarn oxfmt --check` | Passed repository-wide, 3,163 files | + +For each file below, the exact checker invocation was +`yarn markdown-link-check -c .github\markdown-link-check-config.json `; +all file arguments are worktree-relative: + +| File | Result | +| ------------------------------------------------------------------ | --------------- | +| `packages\agentic\desktop-driver\README.md` | Passed 16 links | +| `packages\agentic\desktop-driver\native\PROTOCOL.md` | Passed 5 links | +| `packages\agentic\desktop-driver\native\windows\README.md` | Passed 8 links | +| `packages\agentic\desktop-driver\references\contributing.md` | Passed 4 links | +| `packages\agentic\desktop-driver\references\native-helpers.md` | Passed 8 links | +| `packages\agentic\desktop-driver\references\current-status.md` | Passed 6 links | +| `packages\agentic\desktop-test\README.md` | Passed 7 links | +| `packages\agentic\desktop-test\references\configuration.md` | Passed 1 link | +| `packages\agentic\desktop-test\references\lifecycle.md` | Passed 1 link | +| `packages\agentic\desktop-test\references\native-qualification.md` | Passed 2 links | +| `packages\agentic\desktop-test\references\current-status.md` | Passed 15 links | +| `packages\agentic\storybook-desktop\references\contributing.md` | Passed; 0 links | +| `.changeset\windows-exact-application-ownership.md` | Passed; 0 links | + +The same ignored evidence root retains `tested-source-revision.txt`, +`compile-source-evidence.json`, each `*-focused-committed.log`, +`test-links.log`, `root-format.log`, and `pre-campaign-audit.log`. +Only appended Windows result capture followed the checks; no macOS ledger +section or native/runtime source was changed. + +## Windows Fabric and Win32 Paper — F10-W native qualification — 2026-11-12 UTC + +**Observed source and dependency:** real application qualification ran at +`8343dc0c48a2de01475454c892e7a295523caa75`, on +`user/jasonvmo/desktop-windows-app-ownership`. The coordinator handoff +`W2-base-decision.md` records **STACK** on #4492 at exactly +`6489386956c0485cb91b478a8f4adc4828f4178b`, implementing its neutral application +host/resources, schema-3 native owner claims, process pins, inspection and +settlement contracts. Application resource naming uses the inherited seam, +without new edits to F11's CLI, service, agent connection, WDIO, session, +grant, client or MCP files. The eventual draft PR base is +`user/jasonvmo/desktop-macos-app-ownership`. Its independently owned head may +advance or never merge; this pinned dependency is not permission to rebase, +merge, rewrite history or push to that branch. + +**Observed environment:** Windows build 26300 x64, Node 26.13.0, active/unlocked +RDP, `sessionKind: remote`, native `windows-session:1`. Every native command ran +under the exclusive W2 lock with `-RequirePhysical`; the session remained +eligible before and after each command, with no exit-75 or session-change +result. The helper used Debug/x64, prebuilt-only resolution, artifact/SHA256 +`863cc2dbfc8f5792d22c8606376d226affbf8655fe4ded62ba78c23a29c516c7`, +source digest +`ea76e931dc4cdebc87e0621a7d730f81faabce5ef902063a0512cd7c2b100f2d`. +Private machine paths are retained only in ignored artifacts. + +The registered Windows Fabric product was reused from the +coordinator-specified `furn-81` build at `bdeb0b291`: there are no Windows +app/native-core Windows source changes relative to that build, and the +generated `RnwNewArch`/`UseFabric` values are true. All five native ordinary-run +identities matched its registered manifest image and AUMID +`40411fc5-8e92-4d46-b68d-b62df44b1366_syyqehzwccsyy!App`. +Windows Metro was owned by W2 on 8081 and passed `/status`; both bundles were +produced from W2. Win32 used its canonical prebuilt REX `ReactTest.exe`, Paper, +and the W2 bundle/component/working-directory descriptor. Native Win32 app +build is **Unsupported**. + +**Observed corrections before the green run:** + +- The selected native helper contract initially failed an existing C++ assertion + that dereferenced a destroyed temporary JSON release result. Commit + `4a4b87f37b574820b93e342b68721d931be07c55` retains the result, and the complete + selected native contract/self-test rerun passed. Release behavior is unchanged. +- The first real consumer run passed 3 and failed 5. The fixture's arbitrary + project settlement root was correctly refused with native reason `ancestor`; + this worktree's ancestors allow broader modification. The final fixture uses + the normal native per-user private-store default, without changing ACLs, + permissions or the provider's fail-closed checks. +- Paired packaged activation produced two different PID/start incarnations, + not two owners of one incarnation. Commit `8343dc0c4` corrects that fixture + assumption: three pairs on **each** endpoint now prove distinct controlling + incarnations, unpinned ambiguity refusal, pinned cross-service exclusion, and + that closing one winner preserves the other. Refused contenders, if present, + are disposed before checking the winners. All observed pairs had two distinct + winners; the shared-PID activation race was not induced. + +The corrected fixture passed format, lint and TypeScript build. Its focused +portable selection passed **31 tests**, with **9 native cases skipped** because +native opt-in was unset; the later real native run, not those skips, qualifies +the cases below. Earlier full portable results remain the preparation results +above. The corrected provider selection passed **6 tests**, including all five +new real-process fixture cases, with 10 unrelated filtered cases skipped. + +**Observed final application result: 9 passed, 0 failed, 0 skipped**, one suite, +at `2026-11-12T16:04:04.248Z`. The 27 evidence rows include iteration receipts +and case verdicts; they are not 27 tests. + +| Case | Windows Fabric packaged | Win32 Paper REX | Native evidence | +| ----------------------------------------------- | ----------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Exact identity | Passed | Passed | Canonical image, PID, seven-digit creation time, session; packaged AUMID or exact REX incarnation | +| Read-only inspection and borrowed sessions | Passed | Passed | Unchanged window ids; two sessions per supervisor launch; first deletion preserves the app | +| Start/session/executable mismatch | Passed | Passed | Both inspect and trusted recovery refuse the wrong identity without ending the real process | +| Ambiguous matching processes/windows | Passed | Passed | Three pairs of two actual incarnations; unpinned inspection refuses, exact pins remain readable | +| Independent-service exact application exclusion | Passed | Passed | Independent helper's pinned attach fails `application-lock-busy`; observations do not take control | +| Containment or explicit fencing | Passed, `uncontained` | Passed, `job-object` | Packaged survivor stays journaled and fenced; REX exits with the crashed helper's job | +| Survivor journal and next-run recovery | Passed | Passed | Acquired application resource; live owner refused; literal-PID helper/owner stops after full native identity verification; next supervisor reconciles before replacement | +| Physical-input mutex | Passed | Passed | Native Shift down, second helper cancelled with `AbortError`/effect `none`, release then transfer | +| Repeated-run reliability | Passed 5/5 | Passed 5/5 | Five distinct PID/start tuples per endpoint, two borrowed sessions each; no exact old process or claim leak | +| Concurrent cleanup isolation | Passed 3/3 pairs | Passed 3/3 pairs | Closing one exact winner leaves the other alive; final cleanup proves every incarnation exited | +| Actual kernel PID recycling | Not run | Not run | Not induced; provider's one-100-ns-tick stale pin and real endpoint start mismatch both passed | +| Shared-PID packaged activation race | Not run | Unsupported, not packaged | Every actual Windows pair created two distinct processes; no claim that this exercised a losing shared-PID launch | + +The crash receipts include packaged PID **10984**, creation time +`2026-11-12T16:02:38.6619313Z`, helper **37916**, owner **35708**; and REX +PID **35844**, creation time `2026-11-12T16:03:18.2246578Z`, helper **34864**, +owner **37256**. Native evidence verified each fault-injection identity again +immediately before a single literal-PID stop. The packaged process survived +helper death, the live owner fenced replacement, and after owner exit the next +supervisor ended that exact app and removed the old claim. REX's Job Object +drained on helper death; its next supervisor reconciled the acquired dead app +and claim. No broad name-based termination was used. + +Commands below are the inner workspace commands issued through the required W2 +lock with `-RequirePhysical`. `$W2` denotes the assigned worktree, `$E` its +`artifacts\desktop\f10w\native-20261112` evidence directory. `TEMP`/`TMP` point +to `$W2\artifacts\desktop\f10w\scratch`; the native cache is +`$W2\artifacts\desktop\f10w\n`, configuration `debug`, build policy `never`. + +| Workspace / command | Observed result | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| Storybook: `yarn desktop-driver build-driver --platform windows --configuration debug --cache-root "$W2\artifacts\desktop\f10w\n"` | Passed initial explicit build; provider contract explicitly rebuilt the corrected source | +| Storybook: `yarn desktop-driver doctor --platform windows --configuration debug --cache-root "$W2\artifacts\desktop\f10w\n"` | Passed selected final artifact | +| Storybook: `yarn storybook bundle --win32` | Passed; run `14c14c80-0da7-48ad-ac14-47e0c257fbe1` | +| Storybook: `yarn storybook bundle --windows` | Passed; run `f6ba6fb4-25c9-49a2-9928-8b2430abc83b` | +| Repository root: `node .yarn\releases\yarn-4.18.0.cjs workspace @fluentui-react-native/agentic-components-storybook start --port 8081` | Passed owned Metro `/status` and final shutdown | +| Repository root: `node .yarn\releases\yarn-4.18.0.cjs workspace @fluentui-react-native/desktop-test test --runInBand --cacheDirectory "$W2\artifacts\desktop\f10w\jest-harness" --runTestsByPath src\windowsApplicationOwnership.contract.test.ts --json --outputFile "$E\consumer-results.json"` | Passed 9, failed 0, skipped 0 | + +The consumer inherited `FURN_NATIVE_DRIVER_TEST=1`, +`FURN_NATIVE_APPLICATION_OWNERSHIP_TEST=1`, +`FURN_NATIVE_PHYSICAL_INPUT_TEST=1`, and the two explicit JSON descriptors in +`windows-application.json` / `win32-application.json`. REX's descriptor came +from the owning adapter, not guessed host arguments. No input features were +masked. The command runner owned Metro and awaited its normal resource cleanup; +the outer report's “lifecycle (no tests)” is not the native test verdict. + +**Ignored evidence:** `artifacts/desktop/f10w/native-20261112/` contains +`consumer-results.json`, `consumer-evidence-final.json`, +`qualification-summary.json`, `selected-helper.json`, +`application-build-provenance.json`, `metro-identity.json`, `doctor-debug.log`, +both bundle logs, corrected provider logs, and preserved initial failed +attempts. Canonical native evidence is +`packages/agentic/desktop-test/artifacts/desktop/windows-application-ownership-GiW4x1/evidence.json`; +the outer command report is +`artifacts/desktop/f10w/native-20261112/commands/4ed98f8f-8ebb-49ca-ac90-2ee6caece7dc/run.json`. +Registration, executable/bundle hashes, source revision, real PID/start/session +receipts, and every case outcome are retained there. Machine-specific paths +and crash-owner request/ready records remain ignored, not public. + +**Observed closeout:** all W2 campaigns ended. Final audit: +`FURN-AUDIT: no FURN desktop processes found`; no owned helper, fixture, Metro, +REX or packaged app survived. No push or PR was made. Independent review, the +final one-time locked clean-cache `buildci`, and hosted CI remain **Not run**. +The existing three-package patch changeset is +`windows-exact-application-ownership.md`. + +**Limits:** the tests do not establish arbitrary uncontained-descendant cleanup, +mixed Storybook-graph recovery, forced kernel PID recycling, the unobserved +shared-PID packaged activation race, or a new protected-agent recovery API. +No macOS renderer claim is made by this Windows campaign. Part of #4447 and +#4435. + +## Windows Fabric and Win32 Paper — recovery review follow-up — 2026-11-12 UTC + +**Source:** `8b2fb7e67dc5864180c7863ac94fc3c0c3c9d6e3`, following the +review of `6489386956c0..6bdcde61f`. The branch remains stacked on #4492 at +`6489386956c0485cb91b478a8f4adc4828f4178b`; the base decision recorded in +the preceding Windows sections is unchanged. No F11-owned file was changed +for these fixes; no rebase, dependency-branch write, push, or PR occurred. + +**Implemented:** parsing, target validation and native old-owner probing are +read-only and precede recovery-barrier creation. The original claim's file +identity and exact contents are rechecked under the barrier before any +application cleanup. A versioned `.recovery-v2` barrier atomically publishes +the recoverer's verified native PID/start in a unique empty owner directory. +An empty versioned root authorizes no cleanup. A later native recovery may +retire unchanged pins only after proving their recoverers exited or their +PIDs were reused, even if the old claim was already removed. Live, unknown, +malformed and legacy unowned barriers remain fenced. Replacement directories +detected during probing/cleanup are never adopted or removed. + +The paired native change settles an application's reused recorded PID as an +exited incarnation without signalling its replacement. A matching application +start still requires full identity and application-control exclusion; an +unreadable start remains fenced. Prior native results that expected stale-start +recovery rejection are historical, not qualification of this corrected behavior. + +**Observed test-first evidence:** focused tests on pre-fix `6bdcde61f` produced +**9 failed / 31 passed**: a barrier appeared during the live-owner probe, a +foreign target caused a temporary barrier, a changed claim inherited recovery +authority, and interrupted barriers could not be reconciled. Ignored +`artifacts/desktop/f10w/review-20261112/barrier-red.log` retains those failures. +After the fixes and additional publication/replacement-race coverage: + +| Case | Outcome | +| ------------------------------------------------------------------------------ | ----------------------------------------- | +| No barrier for a live owner or foreign target | **Passed**, portable | +| Claim replaced during barrier acquisition cannot inherit cleanup authority | **Passed**, portable | +| Verified dead/reused recovery owner can resume partial recovery | **Passed**, portable | +| Live/unknown owner, malformed pins and legacy barriers stay fenced | **Passed**, portable | +| Interrupted publication and dead barrier after claim deletion can be retired | **Passed**, portable | +| Replacement-barrier preservation and original-plus-cleanup error retention | **Passed**, portable | +| Corrected native application-start semantics and actual killed recoverer | **Not run** on this source; grant pending | +| Fabric/REX repeated runs, crash recovery, concurrency and physical mutex rerun | **Not run** on this source; grant pending | + +**Observed commands/results:** + +- `yarn format`, `yarn lint`, `yarn build` in the owning harness: **Passed**. +- `yarn workspace @fluentui-react-native/desktop-test test --runInBand windowsOwnership supervisor applicationOwnership`: + **Passed**, **77 passed / 21 native skipped**. Windows ownership alone has + **42 passing** portable cases. `focused-results.json` and + `focused-tests.log` retain the expanded invocation and results. +- `yarn workspace @fluentui-react-native/desktop-test test --runInBand`: + **Passed**, **453 passed / 28 native skipped**, in + `harness-results-final.json` and `harness-tests-final.log`. An earlier full + attempt caught one replacement-barrier diagnostic regression; it was fixed + without weakening foreign-barrier preservation and the full suite rerun. +- Driver package format/lint/build and **1,234** portable tests: **Passed**. + Compile-only Debug/x64 helper rebuild under the machine lock: **Passed**; + source digest `3558e8597f6cfe1ee3f4e38dca2137710b80d193a5c30bf946d54065bf8bc153`. + See the driver's matching Windows section for its binary receipt. + +**Native rerun prepared, not executed:** the actual-endpoint suite now has +**11** cases. On each endpoint, an injected stale owner/application journal +keeps the real replacements live and owned. A real child recoverer publishes +its barrier, fences another startup, and is stopped by an identity-verified +literal PID. The next supervisor must reconcile that dead barrier and retire +the absent recorded application without signalling its live replacement. +Injected start mismatch is not a claim of actual kernel PID recycling. +The existing five-run, crash-survivor, concurrent-incarnation, and gated +physical-mutex cases remain. Run under the granted W2 campaign with both +explicit descriptors and native/physical opt-ins; no native skip is a pass. +No W2 campaign was started in this phase; no renderer/session qualification is +claimed. The existing patch changeset remains +`windows-exact-application-ownership.md`. Final clean-cache `buildci`, +publication and hosted CI remain **Not run**. Part of #4447 and #4435. + +**Observed closeout:** edited-reference/ledger Markdown links, root +`yarn oxfmt --check` (**3,163 files**), `yarn check-agent-instructions`, +`yarn change:check`, and `git diff --check` **Passed**. Both retained/rebuilt +helper hashes were verified. The concurrent audit initially observed only +the portable formatter's two Node processes; after they exited, the audit +reported `FURN-AUDIT: no FURN desktop processes found`. No W2 campaign was +started and no owned desktop process survives. `validation-summary.json` +and `closeout-audit.log` retain the ignored phase receipts. These portable, +compile and hygiene results do not replace the pending native rerun. + +## Windows Fabric and Win32 Paper — default-environment buildci — 2026-11-12 UTC + +**Observed:** root `yarn clean-lage-cache` followed by `yarn lage buildci` +passed at `68d63ccfbf008b963e59772f57aca8ac8f9ff05f`, under the W2 machine +lock with no physical-session requirement. Default TEMP, default concurrency +and repository guidance were unchanged. + +| Attempt | Outcome | +| ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| Initial W2 gate, same revision | **Failed**: 173 successful tasks, one failed Storybook task; server-integration child exit 1 and inline-worker aggregate timeout | +| Full pinned-base comparison at `6489386956c0485cb91b478a8f4adc4828f4178b` | **Passed**: 178 successful tasks, 0 failed/skipped | +| Unchanged W2 full gate rerun | **Passed**: 178 successful tasks, 0 failed/skipped, 2m 32.26s | + +No source fix, dependency installation, timeout change, concurrency override, +or guidance workaround was made. The initial failure did not reproduce in the +base comparison or the unchanged W2 rerun. Native opt-ins remained unset: +this aggregate is not Windows Fabric or Win32 Paper runtime qualification. + +All three logs and `buildci-default-summary.json` are retained in ignored +`artifacts/desktop/f10w/review-20261112/`; the final process audit is +`buildci-default-final-audit.log` and **Passed**: +`FURN-AUDIT: no FURN desktop processes found`. +No application/native contract was launched, no desktop campaign started, +and no push occurred. Corrected native recovery re-verification remains +**Not run**, awaiting an active/unlocked session and its separate grant. +Part of #4447 and #4435. + +## Windows Fabric and Win32 Paper — recovery native re-verification — 2026-11-12 UTC + +**Observed source:** native helper and harness implementation +`dbaf558939db31b4a3935ad68b1eb20d25d90d3e`, still stacked on #4492 at +`6489386956c0485cb91b478a8f4adc4828f4178b`. The consumer test alone had +the one-line assertion correction committed with this section: the public +`NativeDesktopHost.recoverApplication` returns `Promise`, after checking +the native `{ state: 'exited' }` verdict. Its consumer must expect an undefined +resolution, not the wire-level result. No production, protocol or F11 +file changed in this phase. The exact test diff and test-file digest are retained +in ignored `artifacts\desktop\f10w\review-native-20261112`. + +**Observed session and application provenance:** both granted W2 campaigns ran +in active/unlocked **remote** session 1, input desktop `Default`. Every native +wrapper used the machine lock and `-RequirePhysical`; before/after gates stayed +eligible, with no exit 75 or session change. The final consumer wrapper ran +`21:54:54Z–21:57:02Z`. Registration and image hash were checked before each +phase: packaged Fabric remained the `furn-81` Debug/x64 build from `bdeb0b291`, +with no relevant native-app/source difference. Its executable SHA-256 remained +`df9cb3559843fcfb0158ee4ab5edbae78890c4e231ebd895cbb636c24b18cd45`. +The AUMID was +`40411fc5-8e92-4d46-b68d-b62df44b1366_syyqehzwccsyy!App`. +Win32 Paper used the worktree's canonical prebuilt REX +`0.81.1-6828073195` image, its exact arguments and a fresh Win32 bundle; +a Win32 native application build remains **Unsupported**. + +Fresh Win32 and Windows bundles **Passed**, with run IDs +`3830f2d5-30b1-405b-a205-71f45dcc9490` and +`e2fb42e1-c5ec-41b3-bae3-64cedad0aeb6`. Each consumer invocation owned a +fresh Metro, verified its listener's native PID/start/session and worktree +command line, and stopped it in `finally`. No preexisting Metro was adopted. +Native scratch/cache paths stayed inside the worktree. + +**Observed test-first sequence:** + +1. Retained old helper `863cc2dbfc8f5792d22c8606376d226affbf8655fe4ded62ba78c23a29c516c7`, + source `ea76e931dc4cdebc87e0621a7d730f81faabce5ef902063a0512cd7c2b100f2d`: + **4 expected failures / 0 passed / 7 filtered skips**. Both endpoints rejected + stale-start recovery, including interrupted-recoverer journal settlement, + with `identity-start-mismatch`. This was the required native RED, not an + environment failure. +2. Review-fixed helper + `97f0072ce22449bc5c87c05de259d3e4c46eca666e6135977432b30fa16053b2`, + source `3558e8597f6cfe1ee3f4e38dca2137710b80d193a5c30bf946d54065bf8bc153`: + the first complete run had **9 passed / 2 failed / 0 skipped**. Both failures + were the consumer's wrong return-value assertion; recovery resolved + successfully, but the test expected the native wire object. The existing + host identity unit contract already expects `undefined`. +3. Correct only that assertion to `.resolves.toBeUndefined()`, retaining the + independent assertion that the real replacement is alive and all + matching-start mismatch refusals. Rerun the whole native consumer suite: + **11 passed / 0 failed / 0 skipped**. + +| Consumer case | Windows Fabric packaged | Win32 Paper REX | +| -------------------------------------------------------------------------------------- | ---------------------------------------- | ---------------------------------------- | +| Exact identity, independent-service exclusion, five launches/teardowns without leaks | **Passed**, 5/5 distinct PID/start pairs | **Passed**, 5/5 distinct PID/start pairs | +| Stale-start settlement; matching-start session/executable/AUMID mismatches fail closed | **Passed** | **Passed** | +| Crashed recoverer, stale journal and dead recovery barrier; live replacement preserved | **Passed** | **Passed** | +| Crashed owner/helper and exact application journal recovery | **Passed**, packaged survivor recovered | **Passed**, job-object drain reconciled | +| Concurrent pairs, ambiguity refusal, pinned exclusion and independent teardown | **Passed**, 3/3 pairs | **Passed**, 3/3 pairs | +| Physical input mutex, cancellation without effect, release and transfer | **Passed**, one cross-endpoint case | Same cross-endpoint case | + +Every repeated identity reported `windows-session:1`. Fabric consistently +reported `uncontained`; REX consistently reported `job-object`. All three +concurrent pairs on each endpoint acquired two distinct incarnations: the +shared-PID packaged activation race was **Not induced**. Interrupted recoverers +were identity-verified Node processes stopped by literal PIDs **54440** and +**55808**, with recorded native starts +`2026-11-12T21:55:28.3844388Z` and +`2026-11-12T21:56:07.0500082Z`. The subsequent supervisor retired the dead +recovery barrier and injected stale application record without signalling the +real replacement. These injected recorded-start mismatches are **not** actual +kernel PID recycling. + +**Exact consumer commands:** `$W2` denotes the assigned worktree and `$E` +its `artifacts\desktop\f10w\review-native-20261112` directory. From `$W2`, +inside the granted campaign and physical-gated machine lock: + +```powershell +node -- artifacts\desktop\f10w\review-native-20261112\run-consumer.mjs red artifacts\desktop\f10w\native-20261112\selected-helper.json +node -- artifacts\desktop\f10w\review-native-20261112\run-consumer.mjs green artifacts\desktop\f10w\review-20261112\selected-helper.json +``` + +The retained bounded orchestration uses `NodeDesktopCommandRunner`, the two +explicit application descriptors, all three native/application/physical +opt-ins, the selected immutable helper and `buildPolicy=never`. Its GREEN +Jest child command is: + +```powershell +yarn workspace @fluentui-react-native/desktop-test test --runInBand ` + --cacheDirectory "$W2\artifacts\desktop\f10w\jest-harness" ` + --runTestsByPath src\windowsApplicationOwnership.contract.test.ts ` + --json --outputFile "$E\green-consumer-results.json" +``` + +RED adds `--testNamePattern 'native start|crashed recoverer'` and uses +`red-consumer-results.json`. The final real consumer evidence directory is +`windows-application-ownership-3BLzLV`; outer command report +`32f3d6be-c4b0-4792-aeb6-a5d7df165fc0` is lifecycle-only, not the test-count +source. `green-consumer-results.json`, `green-consumer-evidence.json`, +`green-invocation.json`, registration receipts and `consumer-assertion-fix.diff` +retain the actual counts, identities and dirty-test provenance. Expected RED +and first-GREEN failures are preserved separately, not overwritten. + +**Observed additional validation:** + +- `yarn workspace @fluentui-react-native/desktop-test format`, `lint`, and + `build`: **Passed**. +- `yarn workspace @fluentui-react-native/desktop-test test --runInBand +--cacheDirectory "$W2\artifacts\desktop\f10w\jest-harness" +--json --outputFile "$E\harness-portable-results.json"`: + **Passed**, **453 passed / 28 native skipped**. +- `yarn workspace @fluentui-react-native/desktop-driver test --runInBand +NativeDesktopHost.applicationIdentity +--cacheDirectory "$W2\artifacts\desktop\f10w\jest-driver" +--json --outputFile "$E\host-identity-portable-results.json"`: + **Passed**, **31 passed**, including the existing void-return and unsettled + native-verdict contracts. +- The driver's matching native selection: **Passed**, **6 passed / 10 + unrelated filtered skips**, including native build/cache/handshake/self-test + and the physical mutex. Its ledger records the exact invocation. +- Locked default-environment `yarn clean-lage-cache` followed by + `yarn lage buildci`: initial attempt **Failed**, 176 successful tasks and one + Storybook task failure, `A stopping command cannot publish readiness` in + `serverIntegration`. The reported intermittent readiness failure was observed + again; its cause remains unproven. After remaining fail-fast test children + settled and the audit was clean, the **one** unchanged full rerun **Passed**: + **178 successful / 0 failed / 0 skipped**, 2m 38.65s. Neither attempt changed + default TEMP, concurrency, timeouts, dependencies or repository guidance. + Logs are `buildci-default.log` and `buildci-default-rerun.log`. + +**Closeout and limits:** both W2 campaigns ended. Native and final aggregate +audits **Passed**: `FURN-AUDIT: no FURN desktop processes found`. No W2 app, +helper, fixture or Metro survived. The existing three-package patch changeset +`windows-exact-application-ownership.md` remains applicable. The test-only +correction requires coordinator review before push; this phase has not pushed, +edited the PR body, or started new hosted CI. Initial PR #4494 CI at `dbaf558939` +was observed green; it does not qualify this new local test correction. +F11 claim naming remains deferred and untouched. Real kernel PID recycling, +shared-PID activation, arbitrary uncontained descendants and mixed-graph +recovery remain unqualified. Part of #4447 and #4435. + +**Observed reference checks:** Markdown link checks for both edited ledgers +**Passed** (15 harness links, 6 driver links). Root `yarn oxfmt --check` +(3,163 files), `yarn check-agent-instructions`, `yarn change:check`, and +`git diff --check` **Passed**. Receipts are retained beside the native results. + ## F15 macOS feasibility and resolved permission block — 2026-11-12 The table below preserves the initial, non-AX pass. The permission prerequisite diff --git a/packages/agentic/desktop-test/references/lifecycle.md b/packages/agentic/desktop-test/references/lifecycle.md index 3aa581dca9..67b29d5707 100644 --- a/packages/agentic/desktop-test/references/lifecycle.md +++ b/packages/agentic/desktop-test/references/lifecycle.md @@ -14,7 +14,7 @@ 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 defaults to session-owned. macOS targets may +A launched application's lifetime defaults to session-owned. Native 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 @@ -109,7 +109,8 @@ on Windows, and `~/.cache/fluentui-react-native/desktop-test/workload` elsewhere independent of `TMPDIR`, so every checkout contends for the same claim. Claim records are replaced atomically. If a crash leaves a lock, inspect its recorded owner and verify that process and its resources have stopped before removing that exact lock. -There is no automatic stale-lock recovery. +There is no general automatic stale-lock recovery. The bounded Windows native +application reconciliation described below is the only startup exception. The supervisor registers each owned resource before acquisition starts. If cancellation wins while acquisition is in flight, shutdown waits for that work; @@ -141,6 +142,8 @@ is separate from explicit workload-claim delegation. 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. +Windows/Win32 also retain the canonical executable, native session, and AUMID +when packaged, including on a failed cleanup. These records appear in the workload claim alongside services and survive a failed cleanup. Publication failure prevents dispatch or fences an already dispatched launch. A failed/cancelled unleased launch settles when the provider @@ -158,12 +161,71 @@ identity from `Date`, LaunchServices dates, a title, or a bundle ID. Existing Storybook attach-lease code is deliberately unchanged pending its integration migration. +On Windows/Win32 they require all seven native fractional creation-time digits, +canonical executable, session, and the captured AUMID when packaged. The +REX image/PID/start/session tuple denotes one host incarnation. A one-tick +start mismatch, conflicting descriptor, ambiguous process/window match, or +unknown evidence is rejected. Read-only identity inspection does not take +application/input control or change live window identifiers. + 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. +macOS supervisor crash recovery, interrupted publication without a captured +identity, and arbitrary descendant reconciliation remain explicit delivery gates. + +### Bounded Windows application reconciliation + +Before creating its replacement claim or listener, the Windows native supervisor +may reconcile a schema 3 claim for the same registered target and physical +instance. The native owner probe must prove the old owner exited or its PID now +has a different exact creation time. A live or unknown owner never authorizes +cleanup. Each owned application must have a successfully acquired lease, ready +resource record, complete Windows identity, session/supervisor lifetime, and +consistent launch kind/containment. Borrowed applications are never terminated. + +Read-only parsing, target validation and the native owner probe precede barrier +publication; a live or foreign claim creates no barrier. Recovery then holds an +exclusive sibling `.recovery-v2` directory and rechecks the exact claim file +identity/content under it and around asynchronous work. Its empty, uniquely named +owner directory atomically records the recoverer's verified native PID/start. +No recovery effect is allowed before that pin is the sole, identity-checked +owner. An empty versioned root therefore represents interrupted publication and +can be retired safely. + +A subsequent native recovery may remove only the unchanged unique pin of a +recoverer proven exited or PID-reused, including after the claim was already +removed. A live or unknown recoverer, malformed pins, replacement directories, +and legacy unowned `.recovery` barriers remain fenced. Clearing a stale barrier +does not itself authorize application cleanup; the old claim must still pass +all recovery checks. + +The native recoverer first queries application creation time with limited +rights. A reused application PID proves the recorded incarnation exited and is +settled without signalling, controlling, or requesting termination rights to its +current occupant. An unreadable start, including access denied, remains fenced. +For a matching start, the query handle prevents PID reuse while full identity +and per-incarnation control are acquired for exact cleanup. Each successful +application settlement is durably recorded before proceeding, so a failed later +cleanup does not replay settled resources. Only then may the old claim be removed. + +The recognized in-owner WebDriver listener dies with that exact Node owner. +Unknown services, process-backed service receipts, pending/unleased launches, +mixed resource graphs, and effects other than matching recorded resource +disposal remain fenced **before** any application cleanup is dispatched. +Custom-host factories and delegated claims do not acquire this native recovery +authority implicitly. This does not clear durable native/Storybook mutation +quarantine or provide a model-facing recovery route. + +Windows unpackaged launches report `direct-child` / `job-object`. Packaged +activation reports `packaged-activation` / `uncontained`, deliberately avoiding +an unreliable post-activation job assignment. Failed cleanup keeps its acquired +exact primary process in the claim; exact recovery does not prove arbitrary +uncontained descendants exited. The helper never puts an attached application +in an owned job. + +### Service composition and durable quarantine Both native service paths use the real `driverService.ts` composition root (`@fluentui-react-native/desktop-test/driver-service`). It resolves prebuilt @@ -188,7 +250,7 @@ record is valid. Only an exact recorded process ID/start-time probe can prove application exit; native exit events and service death are not proof. `service.recoverTarget` retains the driver's trusted exact-quarantine-ID operator API; no new listing, offline recovery command, or model-facing -recovery route is exposed. Automatic stale-claim removal remains unsupported. +recovery route is exposed. General automatic stale-claim removal remains unsupported. Inspect exact resources before explicit operator recovery; restarting alone never clears uncertain native or Storybook mutations. diff --git a/packages/agentic/desktop-test/references/native-qualification.md b/packages/agentic/desktop-test/references/native-qualification.md index 1b59bd04ce..c604f7ecdb 100644 --- a/packages/agentic/desktop-test/references/native-qualification.md +++ b/packages/agentic/desktop-test/references/native-qualification.md @@ -94,6 +94,94 @@ 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. +## Neutral Windows application ownership + +The Windows provider fixture suite is opt-in and needs an eligible interactive +session and the exclusive native-validation lock: + +```powershell +$env:FURN_NATIVE_DRIVER_TEST = '1' +yarn workspace @fluentui-react-native/desktop-driver test --runInBand applicationOwnership.contract +``` + +It exercises native read-only inspection, exact 100 ns start/session/executable/AUMID +checks, reused-incarnation recovery without signalling the live replacement, +ambiguity, cross-helper application exclusion, and five contained fixture +launch/close cycles. Its additional input-mutex case requires +`FURN_NATIVE_PHYSICAL_INPUT_TEST=1`. A skipped physical case is not input +qualification. The Win32 fixture is supporting provider evidence, not Windows +Fabric or REX application qualification. + +For actual consumer qualification, first explicitly build the helper, prepare +the Windows Fabric packaged app and its own Metro instance, and bundle the +Win32 Paper REX app. Set `FURN_OWNERSHIP_WINDOWS_APPLICATION` and +`FURN_OWNERSHIP_WIN32_APPLICATION` to JSON application descriptors resolved from +the owning app's canonical config. The Windows descriptor must identify the +registered AUMID and exact title; the REX descriptor includes the verified +`ReactTest.exe`, working directory, bundle/component arguments, and exact title. +These are trusted test inputs, not WebDriver capabilities. Do not select another +worktree's Metro or fabricate an application identity. + +Under the exclusive lock and connected/unlocked desktop gate: + +```powershell +$env:FURN_NATIVE_DRIVER_TEST = '1' +$env:FURN_NATIVE_APPLICATION_OWNERSHIP_TEST = '1' +$env:FURN_NATIVE_PHYSICAL_INPUT_TEST = '1' +yarn workspace @fluentui-react-native/desktop-test test --runInBand windowsApplicationOwnership.contract +``` + +The suite requires a verified prebuilt helper and both explicit descriptors. +Supervisors use the native per-user private-store default. An arbitrary project +directory is not necessarily a safe replacement: native ancestor-owner/ACL +checks still apply, and a refused root must not be repaired or bypassed merely +to run this suite. +Separately on Windows Fabric packaged and Win32 Paper REX it checks: + +- Five consecutive supervisor launches/teardowns, exact application and claim + evidence, two borrowed sessions per launch, unchanged window identifiers + during inspection, cross-service exclusion, and no surviving old incarnation. +- Start/session/executable/AUMID inspection mismatch rejection without signalling + the app. Recovery retires a mismatched start as an exited recorded incarnation; + other mismatches with a matching start remain rejected. +- A journaled acquired application in a child owner; identity-verified, + literal-PID helper/owner fault injection; a live-owner fence; then real + supervisor startup recovery of the exact old application and claim. + Windows remains `uncontained` and must actually survive helper death; REX's + Job Object must drain instead. +- A stale journal with injected old native owner/application start times while + the actual replacements remain live and owned. A real child recoverer publishes + its barrier, fences another startup, and is stopped by identity-verified + literal PID. The next supervisor must retire that dead recoverer's barrier and + the absent recorded application without signalling its live replacement. + This models PID reuse by start mismatch; it does not claim kernel PID recycling. +- Physical-input mutex contention across the two real endpoint helpers, with + explicit cancellation and release. Run this only under the physical-session + gate; semantic activation does not establish it. + +Three paired launches on each endpoint also prove that winning controllers have +distinct exact incarnations, an unpinned descriptor refuses multiple matches, +another helper cannot attach to either pinned winner, and closing one winner +preserves the other. A multi-instance packaged host may start two distinct +processes; that is not an exclusion failure. When activation instead returns +the same incarnation, a refused contender must not clean up the winner. Record +whether that shared-incarnation activation race was actually induced rather +than treating two distinct winning processes as proof of the losing path. + +Evidence is written to the desktop-test package's +`artifacts/desktop/windows-application-ownership-*/evidence.json`, including +source/helper identities, endpoint descriptors, process evidence and case +outcomes. Also record the registered app build, renderer, session kind, exact +commands, and before/after process audits in the Windows ledger. Keep private +machine paths in ignored artifacts, not public evidence. + +The crash-owner helper is a contract fixture, not a second production service +factory. Unknown resources/effects, unleased launches, legacy/unverified claims, +and live, unknown, or legacy recovery barriers remain fenced rather than +force-reclaimed. Only a verified dead/reused recovery owner can be resumed. This suite +does not qualify mixed Storybook service-graph recovery, arbitrary uncontained +descendants, or a new protected-agent recovery API. + ## 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 0a5b16e969..6171796865 100644 --- a/packages/agentic/desktop-test/src/adapters.ts +++ b/packages/agentic/desktop-test/src/adapters.ts @@ -78,7 +78,7 @@ export async function resolveDesktopApplication( ); } application = { windowTitle, ...configured }; - if (!application.aumid && !application.executablePath && !application.leasePath) { + if (!application.aumid && !application.executablePath && !application.leasePath && !application.processIdentity) { const manifestPath = path.join(target.projectRoot, 'windows', 'ReactApp.Package', 'bin', 'x64', 'Debug', 'AppxManifest.xml'); if (!fs.existsSync(manifestPath)) throw new Error( @@ -112,8 +112,10 @@ export async function resolveDesktopApplication( if (application.workingDirectory) throw new Error('workingDirectory is currently supported only by the Windows helper.'); } else { if (!application.windowTitle) throw new Error('Windows applications require an exact window title.'); - if (!application.aumid && !application.executablePath && !application.leasePath) { - throw new Error('A title alone is not an application identity; configure an executable, AUMID, or verified owner lease.'); + if (!application.aumid && !application.executablePath && !application.leasePath && !application.processIdentity) { + throw new Error( + 'A title alone is not an application identity; configure an executable, AUMID, exact processIdentity, or verified owner lease.', + ); } } return Object.freeze(application); diff --git a/packages/agentic/desktop-test/src/applicationOwnership.ts b/packages/agentic/desktop-test/src/applicationOwnership.ts index 80df1f88d0..ebb864511a 100644 --- a/packages/agentic/desktop-test/src/applicationOwnership.ts +++ b/packages/agentic/desktop-test/src/applicationOwnership.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto'; +import path from 'node:path'; import type { ApplicationLease, @@ -10,14 +11,32 @@ import type { DesktopApplicationResource, DesktopResourceRecord } from './resour export type DesktopApplicationIdentity = Readonly<{ schemaVersion: 1; - endpoint: 'macos'; + endpoint: 'macos' | 'windows' | 'win32'; processId: number; processStartedAt: string; + executablePath?: string; + interactiveSession?: string; + aumid?: 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 }; +export function captureDesktopApplicationIdentity( + lease: ApplicationLease, + endpoint: DesktopApplicationIdentity['endpoint'], +): DesktopApplicationIdentity { + const identity = { + schemaVersion: 1 as const, + endpoint, + processId: lease.processId, + processStartedAt: lease.processStartedAt, + ...(endpoint === 'macos' + ? {} + : { + executablePath: lease.executablePath, + interactiveSession: lease.interactiveSession, + ...(lease.aumid === undefined ? {} : { aumid: lease.aumid }), + }), + }; validateIdentity(identity); return Object.freeze(identity); } @@ -31,9 +50,16 @@ export function desktopApplicationIdentityDescriptor( 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.'); + if ( + identity.endpoint !== 'macos' && + ((application.aumid !== undefined && application.aumid !== identity.aumid) || + (application.executablePath !== undefined && application.executablePath.toLowerCase() !== identity.executablePath!.toLowerCase())) + ) + throw new TypeError('The application descriptor differs from the recorded exact identity.'); + const { schemaVersion: _schemaVersion, endpoint: _endpoint, ...processIdentity } = identity; return Object.freeze({ ...application, - processIdentity: Object.freeze({ processId: identity.processId, processStartedAt: identity.processStartedAt }), + processIdentity: Object.freeze(processIdentity), }); } @@ -52,8 +78,6 @@ type Entry = { record: DesktopResourceRecord; lease?: ApplicationLease; uncertai * 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; @@ -106,6 +130,9 @@ export async function createDesktopApplicationHost(host: DesktopHost, options: D leaseId: lease.id, ...(lease.processId === undefined ? {} : { processId: lease.processId }), ...(lease.processStartedAt === undefined ? {} : { processStartedAt: lease.processStartedAt }), + ...(lease.executablePath === undefined ? {} : { executablePath: lease.executablePath }), + ...(lease.interactiveSession === undefined ? {} : { interactiveSession: lease.interactiveSession }), + ...(lease.aumid === undefined ? {} : { aumid: lease.aumid }), ...(lease.containment === undefined ? {} : { containment: lease.containment }), ...(lease.launchKind === undefined ? {} : { launchKind: lease.launchKind }), }); @@ -126,7 +153,13 @@ export async function createDesktopApplicationHost(host: DesktopHost, options: D ...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 } : {}), + ...(typeof data?.executablePath === 'string' ? { executablePath: data.executablePath } : {}), + ...(typeof data?.interactiveSession === 'string' ? { interactiveSession: data.interactiveSession } : {}), + ...(typeof data?.aumid === 'string' ? { aumid: data.aumid } : {}), + ...(data?.launchKind === 'launch-services' || data?.launchKind === 'direct-child' || data?.launchKind === 'packaged-activation' + ? { launchKind: data.launchKind } + : {}), + ...(data?.containment === 'job-object' || data?.containment === 'uncontained' ? { containment: data.containment } : {}), }); if (!entry.lease) { const effect = error && typeof error === 'object' && 'effect' in error ? error.effect : undefined; @@ -224,7 +257,7 @@ export async function createDesktopApplicationHost(host: DesktopHost, options: D try { await host.probe(); retained = await track(acquire({ ...options.target, host }, true)); - captureDesktopApplicationIdentity(retained, 'macos'); + captureDesktopApplicationIdentity(retained, options.target.endpoint); } catch (error) { try { await wrapped.dispose(); @@ -242,15 +275,34 @@ function validateIdentity(value: unknown): asserts value is DesktopApplicationId if ( !identity || identity.schemaVersion !== 1 || - identity.endpoint !== 'macos' || + !['macos', 'windows', 'win32'].includes(identity.endpoint) || !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) + !( + identity.endpoint === 'macos' + ? /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{6}Z$/ + : /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{7}Z$/ + ).test(identity.processStartedAt) || + !Number.isFinite(Date.parse(identity.processStartedAt)) ) { - throw new TypeError('A macOS application identity requires an exact native PID and microsecond start timestamp.'); + throw new TypeError( + 'An application identity requires an exact native PID and microsecond (macOS) or 100 ns (Windows) start timestamp.', + ); } + if ( + identity.endpoint !== 'macos' && + (typeof identity.executablePath !== 'string' || + !path.win32.isAbsolute(identity.executablePath) || + identity.executablePath.length > 4096 || + /\p{Cc}/u.test(identity.executablePath) || + typeof identity.interactiveSession !== 'string' || + !/^windows-session:[0-9]{1,10}$/.test(identity.interactiveSession) || + (identity.aumid !== undefined && + (typeof identity.aumid !== 'string' || !identity.aumid || identity.aumid.length > 4096 || /\p{Cc}/u.test(identity.aumid)))) + ) + throw new TypeError('A Windows application identity requires a native executable, AUMID when packaged, and session evidence.'); } function errorData(error: unknown): Record | undefined { diff --git a/packages/agentic/desktop-test/src/config.ts b/packages/agentic/desktop-test/src/config.ts index dd585787a1..9bc20501bd 100644 --- a/packages/agentic/desktop-test/src/config.ts +++ b/packages/agentic/desktop-test/src/config.ts @@ -11,6 +11,7 @@ import type { DesktopScopeDomains, DesktopScopeValues } from '@fluentui-react-na import type { DesktopReporterMode } from '@fluentui-react-native/desktop-driver/cli/reporter'; import type { DesktopIntegration } from './integration.ts'; import { orderDesktopResourceContributions } from './resources.ts'; +import { captureDesktopApplicationIdentity } from './applicationOwnership.ts'; export type DesktopCommand = { command: string; @@ -146,8 +147,6 @@ export function validateDesktopConfig(value: unknown): asserts value is DesktopC 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', '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}`); @@ -157,9 +156,7 @@ export function validateDesktopConfig(value: unknown): asserts value is DesktopC throw new TypeError('Win32 uses REX, not react-native-test-app.'); } 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.'); + validateApplication(host.application, `${id}.host.application`, (target.endpoint ?? id) as DesktopEndpoint); } if (host.kind === 'installed' && host.application === undefined) throw new TypeError(`${id}: installed hosts require application identity.`); @@ -453,7 +450,7 @@ function validateTestScope(value: unknown, domains: DesktopScopeDomains, id: str resolveDesktopScopeValues(scope, domains, `${id}.testScope`); } -function validateApplication(value: unknown, context: string): void { +function validateApplication(value: unknown, context: string, endpoint: DesktopEndpoint): void { const application = object(value, context); keys( application, @@ -476,13 +473,19 @@ function validateApplication(value: unknown, context: string): void { 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.`); + keys( + identity, + endpoint === 'macos' + ? ['processId', 'processStartedAt'] + : ['processId', 'processStartedAt', 'executablePath', 'interactiveSession', 'aumid'], + `${context}.processIdentity`, + ); + captureDesktopApplicationIdentity( + { ...identity, id: 'configured-external-identity', ownership: 'attached' } as Parameters< + typeof captureDesktopApplicationIdentity + >[0], + endpoint, + ); } else text(setting, `${context}.${field}`); } if (Boolean(application.leasePath) !== Boolean(application.leaseNonce)) diff --git a/packages/agentic/desktop-test/src/ownership.ts b/packages/agentic/desktop-test/src/ownership.ts index b7111167db..1d42c82321 100644 --- a/packages/agentic/desktop-test/src/ownership.ts +++ b/packages/agentic/desktop-test/src/ownership.ts @@ -8,8 +8,10 @@ import type { DesktopServiceIdentity } from '@fluentui-react-native/desktop-driv 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 { NativeApplicationIdentity } from '@fluentui-react-native/desktop-driver'; import type { DesktopResourceRecord } from './resources.js'; +import { captureDesktopApplicationIdentity } from './applicationOwnership.js'; export type DesktopWorkloadProcessIdentity = Readonly>; @@ -104,6 +106,7 @@ export function createDesktopWorkloadClaim(options: DesktopWorkloadClaimOptions) const root = options.directory ?? defaultDesktopWorkloadDirectory(); fs.mkdirSync(root, { recursive: true, mode: 0o700 }); const filename = path.join(root, 'interactive.lock'); + requireNoRecoveryBarrier(filename); const owner: DesktopWorkloadOwnerIdentity = Object.freeze({ id: randomUUID(), processId: process.pid, @@ -217,6 +220,305 @@ export async function inspectDesktopWorkloadClaim( return { state: 'live', record }; } +/** + * Reconciles only recorded Windows applications and the in-owner driver listener. Unknown resources/effects and + * interrupted acquisitions without native identity remain fenced; this is not general stale-lock reclamation. + */ +export async function recoverWindowsDesktopWorkloadClaim(options: { + directory?: string; + endpoint: 'windows' | 'win32'; + targetId: string; + targetInstanceId: string; + /** The recoverer's verified native identity, also used to reconcile an interrupted recovery barrier. */ + processIdentity: DesktopWorkloadProcessIdentity; + probeOwner: (owner: DesktopWorkloadOwnerIdentity) => Promise; + /** Must prove exact native exit, with application-control exclusion held through cleanup. */ + recoverApplication: (identity: NativeApplicationIdentity) => Promise; +}): Promise<{ state: 'absent' } | { state: 'recovered'; applications: number }> { + const filename = path.join(options.directory ?? defaultDesktopWorkloadDirectory(), 'interactive.lock'); + requireNoLegacyRecoveryBarrier(filename); + if (options.processIdentity?.platform !== 'windows' || !validProcessIdentity(options.processIdentity, process.pid)) + throw new TypeError("Desktop recovery requires this process's native Windows operating-system identity."); + if (!fs.existsSync(filename)) { + await retireWindowsRecoveryBarrier(filename, options); + return { state: 'absent' }; + } + let file = fs.lstatSync(filename); + if (!file.isFile() || file.isSymbolicLink() || file.nlink !== 1 || file.size > 1024 * 1024) + throw new Error('The desktop claim identity is unsafe; recovery remains fenced.'); + let contents = fs.readFileSync(filename, 'utf8'); + let record = parseOwnershipRecord(contents); + if ( + record.schemaVersion !== 3 || + record.owner.processIdentity?.platform !== 'windows' || + record.owner.targetId !== options.targetId || + record.owner.targetInstanceId !== options.targetInstanceId + ) + throw new Error('The desktop claim has an unverified owner or a different target identity; recovery remains fenced.'); + const owner = await options.probeOwner(record.owner); + if (!windowsOwnerExited(owner, record.owner.processStartedAt!)) + throw new Error('The recorded desktop owner is live or unknown; recovery remains fenced.'); + + const claimUnchanged = () => { + const current = fs.lstatSync(filename); + if ( + !current.isFile() || + current.isSymbolicLink() || + current.nlink !== 1 || + current.dev !== file.dev || + current.ino !== file.ino || + fs.readFileSync(filename, 'utf8') !== contents + ) + throw new Error('Desktop claim ownership changed; refusing recovery of another owner.'); + }; + claimUnchanged(); + await retireWindowsRecoveryBarrier(filename, options); + claimUnchanged(); + const barrier = acquireWindowsRecoveryBarrier(filename, options.processIdentity); + const unchanged = () => { + barrier.assertOwned(); + claimUnchanged(); + }; + const failures: unknown[] = []; + let recoveredApplications = 0; + try { + unchanged(); + const resources = new Map(); + const applications: { resource: DesktopResourceRecord; identity: NativeApplicationIdentity }[] = []; + for (const resource of record.resources) { + if ( + !resource || + typeof resource.id !== 'string' || + typeof resource.identity !== 'string' || + !Number.isSafeInteger(resource.generation) || + resource.generation < 1 || + resources.has(resource.id) || + !['owned', 'borrowed'].includes(resource.ownership) || + !['pending', 'settled', 'failed', 'not-owned'].includes(resource.disposal) + ) + throw new Error('A desktop resource identity is invalid; recovery remains fenced.'); + resources.set(resource.id, resource); + if (resource.ownership === 'borrowed') { + if ( + resource.disposal !== 'not-owned' || + (resource.application && (resource.application.lifetime !== 'external' || resource.application.containment !== 'external')) + ) + throw new Error('An external resource has inconsistent ownership; recovery remains fenced.'); + continue; + } + if (resource.disposal === 'settled') continue; + if (resource.application) { + const application = resource.application; + if ( + application.endpoint !== options.endpoint || + typeof application.leaseId !== 'string' || + application.leaseId.length < 1 || + resource.readiness !== 'ready' || + !['session', 'supervisor'].includes(application.lifetime) || + !( + (application.containment === 'job-object' && application.launchKind === 'direct-child') || + (application.containment === 'uncontained' && application.launchKind === 'packaged-activation') + ) + ) + throw new Error('The application resource lacks exact Windows ownership evidence; recovery remains fenced.'); + const { + schemaVersion: _schemaVersion, + endpoint: _endpoint, + ...identity + } = captureDesktopApplicationIdentity({ ...application, id: application.leaseId, ownership: 'launched' }, options.endpoint); + applications.push({ resource, identity }); + } else if ( + resource.id !== 'webdriver' || + resource.identity !== `driver-service:${options.targetId}:${options.targetInstanceId}` || + resource.serviceReady !== undefined + ) { + throw new Error('An owned desktop resource cannot be proven exited; recovery remains fenced.'); + } + } + for (const effect of record.unresolvedEffects) { + const resource = resources.get(effect?.resourceId); + if ( + !resource || + resource.ownership !== 'owned' || + effect.id !== `dispose:${resource.id}:${resource.generation}` || + effect.resourceIdentity !== resource.identity + ) + throw new Error('An unresolved desktop effect requires operator reconciliation; recovery remains fenced.'); + } + for (const { resource, identity } of applications) { + unchanged(); + await options.recoverApplication(identity); + unchanged(); + record = { + ...record, + resources: record.resources.map((entry) => (entry.id === resource.id ? { ...entry, disposal: 'settled' } : entry)), + unresolvedEffects: record.unresolvedEffects.filter((effect) => effect.resourceId !== resource.id), + }; + const next = JSON.stringify(record); + replaceFileAtomically(filename, next); + contents = next; + file = fs.lstatSync(filename); + } + unchanged(); + fs.unlinkSync(filename); + recoveredApplications = applications.length; + } catch (error) { + failures.push(error); + } + try { + barrier.release(); + } catch (error) { + failures.push(error); + } + if (failures.length > 1) throw new AggregateError(failures, 'Desktop workload recovery and barrier cleanup failed.'); + if (failures.length) throw failures[0]; + return { state: 'recovered', applications: recoveredApplications }; +} + +function requireNoRecoveryBarrier(filename: string): void { + requireNoLegacyRecoveryBarrier(filename); + if (recoveryBarrierStat(`${filename}.recovery-v2`)) throw recoveryBusy(); +} + +function requireNoLegacyRecoveryBarrier(filename: string): void { + if (recoveryBarrierStat(`${filename}.recovery`)) throw recoveryBusy(); +} + +function recoveryBusy(cause?: unknown): Error { + return new Error('Desktop workload recovery is busy; a live or unverified recoverer keeps ownership fenced.', { cause }); +} + +function recoveryBarrierStat(filename: string): fs.Stats | undefined { + try { + return fs.lstatSync(filename); + } catch (error) { + if (error !== null && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') return undefined; + throw error; + } +} + +function assertRecoveryDirectory(filename: string, expected: fs.Stats): void { + const current = recoveryBarrierStat(filename); + if (!current || !current.isDirectory() || current.isSymbolicLink() || current.dev !== expected.dev || current.ino !== expected.ino) + throw new Error('Desktop recovery barrier ownership changed; refusing to remove another recovery claim.'); +} + +function windowsOwnerExited(probe: DesktopWorkloadOwnerProbe, startedAt: string): boolean { + return ( + probe.state === 'exited' || + (probe.state === 'alive' && validWindowsStartedAt(probe.processStartedAt) && probe.processStartedAt !== startedAt) + ); +} + +async function retireWindowsRecoveryBarrier( + filename: string, + options: { + targetId: string; + targetInstanceId: string; + probeOwner: (owner: DesktopWorkloadOwnerIdentity) => Promise; + }, +): Promise { + requireNoLegacyRecoveryBarrier(filename); + const root = `${filename}.recovery-v2`; + const directory = recoveryBarrierStat(root); + if (!directory) return; + assertRecoveryDirectory(root, directory); + const entries = fs.readdirSync(root); + if (entries.length > 16) throw recoveryBusy(); + const pins = entries.map((entry) => { + const match = /^owner-([1-9][0-9]{0,9})-([0-9]{21})-([a-f0-9]{32})$/.exec(entry); + if (!match) throw recoveryBusy(); + const processId = Number(match[1]); + const time = match[2]; + const processStartedAt = `${time.slice(0, 4)}-${time.slice(4, 6)}-${time.slice(6, 8)}T${time.slice(8, 10)}:${time.slice(10, 12)}:${time.slice(12, 14)}.${time.slice(14)}Z`; + if (processId > 2147483647 || !validWindowsStartedAt(processStartedAt)) throw recoveryBusy(); + const owner: DesktopWorkloadOwnerIdentity = { + id: match[3], + processId, + processStartedAt, + runId: 'desktop-recovery', + targetId: options.targetId, + targetInstanceId: options.targetInstanceId, + }; + const filename = path.join(root, entry); + const identity = fs.lstatSync(filename); + assertRecoveryDirectory(filename, identity); + if (fs.readdirSync(filename).length) throw recoveryBusy(); + return { filename, identity, owner }; + }); + for (const { owner } of pins) { + if (!windowsOwnerExited(await options.probeOwner(owner), owner.processStartedAt!)) throw recoveryBusy(); + } + assertRecoveryDirectory(root, directory); + const current = fs.readdirSync(root); + if (current.length !== entries.length || current.some((entry) => !entries.includes(entry))) throw recoveryBusy(); + // Only remove the unique dead owner's pin, never a new recoverer's replacement directory. + for (const pin of pins) { + assertRecoveryDirectory(root, directory); + assertRecoveryDirectory(pin.filename, pin.identity); + fs.rmdirSync(pin.filename); + } + assertRecoveryDirectory(root, directory); + try { + fs.rmdirSync(root); + } catch (error) { + throw recoveryBusy(error); + } +} + +function acquireWindowsRecoveryBarrier(filename: string, processIdentity: DesktopWorkloadProcessIdentity) { + requireNoRecoveryBarrier(filename); + const root = `${filename}.recovery-v2`; + try { + fs.mkdirSync(root, { mode: 0o700 }); + } catch (error) { + throw recoveryBusy(error); + } + const directory = fs.lstatSync(root); + assertRecoveryDirectory(root, directory); + const nativeTime = processIdentity.startedAt.replaceAll(/[-:.TZ]/g, ''); + const owner = path.join(root, `owner-${processIdentity.pid}-${nativeTime}-${randomUUID().replaceAll('-', '')}`); + // mkdir publishes the complete native owner atomically; an empty v2 root authorizes no recovery effects. + let identity: fs.Stats; + try { + fs.mkdirSync(owner, { mode: 0o700 }); + identity = fs.lstatSync(owner); + } catch (error) { + try { + assertRecoveryDirectory(root, directory); + fs.rmdirSync(root); + } catch (cleanup) { + throw new AggregateError([error, cleanup], 'Desktop recovery barrier publication and cleanup failed.'); + } + throw error; + } + return { + assertOwned() { + requireNoLegacyRecoveryBarrier(filename); + assertRecoveryDirectory(root, directory); + assertRecoveryDirectory(owner, identity); + const entries = fs.readdirSync(root); + if (entries.length !== 1 || entries[0] !== path.basename(owner)) + throw new Error('Desktop recovery barrier ownership changed; refusing ambiguous recovery.'); + }, + release() { + assertRecoveryDirectory(owner, identity); + fs.rmdirSync(owner); + assertRecoveryDirectory(root, directory); + fs.rmdirSync(root); + }, + }; +} + +function validWindowsStartedAt(value: unknown): value is string { + return ( + typeof value === 'string' && + /^[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) && + Number.isFinite(Date.parse(value)) && + new Date(value).toISOString() === `${value.slice(0, 23)}Z` + ); +} + function replaceFileAtomically(filename: string, contents: string): void { const temporary = `${filename}.${randomUUID()}.tmp`; fs.writeFileSync(temporary, contents, { flag: 'wx', mode: 0o600 }); @@ -273,8 +575,6 @@ function validProcessIdentity(value: DesktopWorkloadProcessIdentity, pid: number (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)) + : value.platform === 'windows' && /^windows-session:[0-9]+$/.test(value.interactiveSession) && validWindowsStartedAt(value.startedAt)) ); } diff --git a/packages/agentic/desktop-test/src/resources.ts b/packages/agentic/desktop-test/src/resources.ts index 062a81fa82..a797783f39 100644 --- a/packages/agentic/desktop-test/src/resources.ts +++ b/packages/agentic/desktop-test/src/resources.ts @@ -6,8 +6,11 @@ export type DesktopApplicationResource = Readonly<{ leaseId?: string; processId?: number; processStartedAt?: string; + executablePath?: string; + interactiveSession?: string; + aumid?: string; containment?: 'external' | 'job-object' | 'uncontained'; - launchKind?: 'launch-services' | 'direct-child'; + launchKind?: 'launch-services' | 'direct-child' | 'packaged-activation'; }>; export type DesktopResourceServiceReadyRecord = Readonly; diff --git a/packages/agentic/desktop-test/src/supervisor.test.ts b/packages/agentic/desktop-test/src/supervisor.test.ts index b447ebb2d5..53080ed50f 100644 --- a/packages/agentic/desktop-test/src/supervisor.test.ts +++ b/packages/agentic/desktop-test/src/supervisor.test.ts @@ -4,13 +4,24 @@ import path from 'node:path'; import { accessibleLaneMaskedFeatures } from '@fluentui-react-native/desktop-driver/authoring'; import type { DesktopInputLane } from '@fluentui-react-native/desktop-driver/authoring'; +import { NativeDesktopHost, NativeDriverError } from '@fluentui-react-native/desktop-driver'; +import type { NativeDriverArtifact } from '@fluentui-react-native/desktop-driver'; +import { describeWindowsService } from '@fluentui-react-native/desktop-driver/authority'; +import { resolveNativeDesktopDriver } from '@fluentui-react-native/desktop-driver/native'; import type { ResolvedDesktopTarget } from './config.js'; import { startNativeDesktopService } from './driverService.js'; import { DesktopRunReporter } from './reporting/collector.js'; import { DesktopSupervisor } from './supervisor.js'; +import type { DesktopSupervisorOptions } from './supervisor.js'; +import { createDesktopWorkloadClaim } from './ownership.js'; jest.mock('./driverService', () => ({ startDesktopService: jest.fn(), startNativeDesktopService: jest.fn() })); jest.mock('./adapters', () => ({ resolveDesktopApplication: jest.fn(async () => ({ executablePath: 'Fixture.app' })) })); +jest.mock('@fluentui-react-native/desktop-driver/native', () => ({ resolveNativeDesktopDriver: jest.fn() })); +jest.mock('@fluentui-react-native/desktop-driver/authority', () => ({ + ...jest.requireActual('@fluentui-react-native/desktop-driver/authority'), + describeWindowsService: jest.fn(), +})); describe('desktop supervisor input lane', () => { const platform = Object.getOwnPropertyDescriptor(process, 'platform')!; @@ -79,3 +90,207 @@ describe('desktop supervisor input lane', () => { expect(options).not.toHaveProperty('disabledInputFeatures'); }); }); + +describe('Windows supervisor survivor recovery', () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform')!; + const startNative = jest.mocked(startNativeDesktopService); + const describeOwner = jest.mocked(describeWindowsService); + const resolveArtifact = jest.mocked(resolveNativeDesktopDriver); + const artifact: NativeDriverArtifact = { + architecture: 'x64', + artifactId: 'artifact', + artifactRoot: 'artifact-root', + buildFingerprint: 'build', + buildId: 'build', + compatibilityKey: 'compatibility', + configuration: 'release', + endpoints: ['windows', 'win32'], + features: ['inspectApplication', 'recoverApplication'], + provider: 'windows', + executablePath: 'C:\\verified\\furn-desktop-driver-host.exe', + origin: 'cache', + schemaVersion: 1, + signing: { mode: 'none' }, + sourceDigest: 'source', + wireProtocol: { major: 1, minor: 0 }, + }; + const oldOwner = { + platform: 'windows' as const, + pid: process.pid, + startedAt: '2026-11-12T00:00:00.1234567Z', + executablePath: process.execPath, + interactiveSession: 'windows-session:2', + }; + const currentOwner = { ...oldOwner, startedAt: '2026-11-12T00:00:00.1234568Z' }; + const application = { + processId: 456, + processStartedAt: '2026-11-12T00:00:00.7654321Z', + executablePath: 'C:\\apps\\ReactApp.exe', + interactiveSession: 'windows-session:2', + aumid: 'package_family!App', + }; + let directory: string; + let recover: jest.SpyInstance; + let dispose: jest.SpyInstance; + const lockDirectory = () => path.join(directory, 'locks'); + const filename = () => path.join(lockDirectory(), 'interactive.lock'); + + beforeEach(() => { + directory = fs.mkdtempSync(path.join(process.cwd(), '.supervisor-recovery-test-')); + Object.defineProperty(process, 'platform', { ...platform, value: 'win32' }); + resolveArtifact.mockReset().mockResolvedValue(artifact); + describeOwner.mockReset().mockResolvedValue(currentOwner); + recover = jest.spyOn(NativeDesktopHost.prototype, 'recoverApplication').mockResolvedValue(undefined); + dispose = jest.spyOn(NativeDesktopHost.prototype, 'dispose').mockResolvedValue(undefined); + startNative.mockReset(); + // eslint-disable-next-line @microsoft/sdl/no-insecure-url -- mocked loopback listener; no socket is opened + startNative.mockResolvedValue({ url: 'http://127.0.0.1:1', close: async () => undefined } as Awaited< + ReturnType + >); + }); + + afterEach(() => { + jest.restoreAllMocks(); + Object.defineProperty(process, 'platform', platform); + fs.rmSync(directory, { recursive: true, force: true }); + }); + + function survivor() { + const claim = createDesktopWorkloadClaim({ + directory: lockDirectory(), + runId: 'crashed', + targetId: 'packaged', + targetInstanceId: 'packaged-instance', + processIdentity: oldOwner, + }); + claim.updateResource({ + id: 'application:1', + identity: 'exact-packaged-incarnation', + generation: 1, + ownership: 'owned', + readiness: 'ready', + disposal: 'pending', + application: { + ...application, + endpoint: 'windows', + leaseId: 'acquired-packaged-lease', + lifetime: 'session', + containment: 'uncontained', + launchKind: 'packaged-activation', + }, + }); + } + + function supervisor(overrides: Partial = {}) { + const output = { write: () => true }; + const reporter = new DesktopRunReporter({ artifactsRoot: directory, output, errorOutput: output, summary: false }); + return new DesktopSupervisor({ + target: { + endpoint: 'windows', + id: 'packaged', + registrationId: 'packaged', + instanceId: 'packaged-instance', + projectRoot: directory, + configPath: path.join(directory, 'desktop.config.mts'), + host: { kind: 'installed', application: { aumid: application.aumid, windowTitle: 'Fixture' } }, + }, + reporter, + reportSession: { reporter, delegated: true, environment: {}, close: async () => 0 }, + lockDirectory: lockDirectory(), + processIdentity: currentOwner, + ...overrides, + }); + } + + test.each([true, false])('a portable scope can open without a prebuilt helper (explicit identity: %s)', async (explicitIdentity) => { + resolveArtifact.mockRejectedValue(new NativeDriverError('no-verified-prebuilt', 'No verified helper was provisioned.')); + const next = supervisor({ processIdentity: explicitIdentity ? currentOwner : undefined }); + try { + await expect(next.open()).resolves.toBeUndefined(); + expect(startNative).not.toHaveBeenCalled(); + expect(recover).not.toHaveBeenCalled(); + expect(JSON.parse(fs.readFileSync(filename(), 'utf8')).owner.processIdentity).toEqual(explicitIdentity ? currentOwner : undefined); + } finally { + await next.close(); + } + expect(fs.existsSync(filename())).toBe(false); + }); + + test('missing native recovery authority leaves an existing application claim fenced', async () => { + survivor(); + const before = fs.readFileSync(filename(), 'utf8'); + resolveArtifact.mockRejectedValue(new NativeDriverError('no-verified-prebuilt', 'No verified helper was provisioned.')); + const next = supervisor(); + try { + await expect(next.open()).rejects.toThrow(/Another desktop workload holds/); + expect(recover).not.toHaveBeenCalled(); + expect(fs.readFileSync(filename(), 'utf8')).toBe(before); + } finally { + await next.close(); + } + }); + + test('helper verification errors still abort before publication without falling back to an unverified claim', async () => { + resolveArtifact.mockRejectedValue(new Error('Native artifact integrity failed.')); + const next = supervisor(); + try { + await expect(next.open()).rejects.toThrow('Native artifact integrity failed.'); + expect(fs.existsSync(filename())).toBe(false); + expect(recover).not.toHaveBeenCalled(); + expect(startNative).not.toHaveBeenCalled(); + } finally { + await next.close(); + } + }); + + test('recovers the exact packaged survivor before publishing the replacement owner and listener', async () => { + survivor(); + recover.mockImplementation(async () => { + expect(JSON.parse(fs.readFileSync(filename(), 'utf8')).owner.runId).toBe('crashed'); + expect(startNative).not.toHaveBeenCalled(); + }); + const next = supervisor(); + try { + await expect(next.start()).resolves.toBeUndefined(); + expect(recover).toHaveBeenCalledWith(application, expect.any(AbortSignal)); + expect(dispose).toHaveBeenCalledTimes(1); + expect(describeOwner).toHaveBeenCalledWith({ artifact, pid: process.pid }); + expect(resolveArtifact).toHaveBeenCalledWith(expect.objectContaining({ platform: 'windows', buildPolicy: 'never' })); + expect(startNative).toHaveBeenCalledWith(expect.objectContaining({ artifact })); + expect(JSON.parse(fs.readFileSync(filename(), 'utf8')).owner.processStartedAt).toBe(currentOwner.startedAt); + } finally { + await next.close(); + } + expect(fs.existsSync(filename())).toBe(false); + }); + + test.each(['live', 'unknown'] as const)('leaves a %s owner fenced before any application cleanup or listener start', async (kind) => { + survivor(); + if (kind === 'live') describeOwner.mockResolvedValue(oldOwner); + else describeOwner.mockRejectedValue(Object.assign(new Error('Cannot inspect owner'), { reason: 'helper' })); + const next = supervisor(); + try { + await expect(next.start()).rejects.toThrow(/owner.*live|owner.*unknown|live or unknown/); + expect(recover).not.toHaveBeenCalled(); + expect(startNative).not.toHaveBeenCalled(); + expect(fs.existsSync(filename())).toBe(true); + } finally { + await next.close(); + } + }); + + test('a refused survivor identity keeps the old claim and disposes the recovery-only helper', async () => { + survivor(); + recover.mockRejectedValue(new Error('identity-start-mismatch')); + const before = fs.readFileSync(filename(), 'utf8'); + const next = supervisor(); + try { + await expect(next.start()).rejects.toThrow('identity-start-mismatch'); + expect(startNative).not.toHaveBeenCalled(); + expect(dispose).toHaveBeenCalledTimes(1); + expect(fs.readFileSync(filename(), 'utf8')).toBe(before); + } finally { + await next.close(); + } + }); +}); diff --git a/packages/agentic/desktop-test/src/supervisor.ts b/packages/agentic/desktop-test/src/supervisor.ts index f5c54dcd5e..d3ff92b6e0 100644 --- a/packages/agentic/desktop-test/src/supervisor.ts +++ b/packages/agentic/desktop-test/src/supervisor.ts @@ -1,6 +1,9 @@ import path from 'node:path'; -import type { DesktopDriverServer, DesktopHost } from '@fluentui-react-native/desktop-driver'; +import { NativeDesktopHost, NativeDriverError } from '@fluentui-react-native/desktop-driver'; +import type { DesktopDriverServer, DesktopHost, NativeDriverArtifact } from '@fluentui-react-native/desktop-driver'; +import { describeWindowsService } from '@fluentui-react-native/desktop-driver/authority'; +import { resolveNativeDesktopDriver } from '@fluentui-react-native/desktop-driver/native'; import { accessibleLaneMaskedFeatures } from '@fluentui-react-native/desktop-driver/authoring'; import type { DesktopInputLane } from '@fluentui-react-native/desktop-driver/authoring'; import type { DesktopAgentConnectionOptions } from './agentConnection.js'; @@ -11,7 +14,7 @@ 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, describeDesktopWorkloadOwner } from './ownership.js'; +import { createDesktopWorkloadClaim, describeDesktopWorkloadOwner, recoverWindowsDesktopWorkloadClaim } from './ownership.js'; import type { DesktopWorkloadClaim, DesktopWorkloadProcessIdentity } from './ownership.js'; import { resolveDesktopApplication } from './adapters.js'; import { createDesktopApplicationHost } from './applicationOwnership.js'; @@ -57,6 +60,7 @@ export class DesktopSupervisor { private resourceStartPromise?: Promise; private closePromise?: Promise; private ownershipUncertain = false; + private nativeArtifact?: NativeDriverArtifact; private readonly environment: Record = {}; private readonly workers = new Set(); private readonly options: DesktopSupervisorOptions; @@ -87,14 +91,34 @@ export class DesktopSupervisor { if (this.options.workload?.kind === 'delegated') { this.workloadClaim = this.options.workload.claim; } else { + let processIdentity = this.options.processIdentity; + if (!this.options.hostFactory && process.platform === 'win32' && this.target.endpoint !== 'macos') { + try { + this.nativeArtifact = await resolveNativeDesktopDriver({ + ...this.target.nativeDriver, + platform: this.target.endpoint, + buildPolicy: 'never', + signal: this.services.controller.signal, + }); + } catch (error) { + this.nativeArtifact = undefined; + // A portable resource/reporting scope need not start a native service. It cannot recover without a helper. + if (!(error instanceof NativeDriverError) || error.code !== 'no-verified-prebuilt') throw error; + } + if (this.nativeArtifact) { + processIdentity ??= await describeWindowsService({ artifact: this.nativeArtifact, pid: process.pid }); + await this.recoverWindowsClaim(this.nativeArtifact, processIdentity); + } + } else { + processIdentity ??= this.options.hostFactory ? undefined : await describeDesktopWorkloadOwner(this.target.nativeDriver); + } + this.services.controller.signal.throwIfAborted(); this.workloadClaim = createDesktopWorkloadClaim({ runId: this.reporter.runId, 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)), + processIdentity, }); this.ownsWorkloadClaim = true; } @@ -119,6 +143,47 @@ export class DesktopSupervisor { } } + private async recoverWindowsClaim(artifact: NativeDriverArtifact, processIdentity: DesktopWorkloadProcessIdentity): Promise { + const endpoint = this.target.endpoint; + if (endpoint === 'macos') throw new Error('Windows workload recovery requires a Windows endpoint.'); + let host: NativeDesktopHost | undefined; + let failure: unknown; + try { + await recoverWindowsDesktopWorkloadClaim({ + directory: this.options.lockDirectory, + endpoint, + targetId: this.target.id, + targetInstanceId: this.target.instanceId, + processIdentity, + probeOwner: async (owner) => { + try { + const described = await describeWindowsService({ artifact, pid: owner.processId }); + return { state: 'alive', processStartedAt: described.startedAt }; + } catch (error) { + return error !== null && typeof error === 'object' && 'reason' in error && error.reason === 'exited' + ? { state: 'exited' } + : { state: 'unknown' }; + } + }, + recoverApplication: async (identity) => { + host ??= new NativeDesktopHost({ artifact, application: {}, endpoint }); + await host.recoverApplication(identity, this.services.controller.signal); + }, + }); + } catch (error) { + failure = error; + } + try { + await host?.dispose(); + } catch (cleanup) { + throw new AggregateError( + failure === undefined ? [cleanup] : [failure, cleanup], + 'Exact Windows workload recovery or helper disposal failed.', + ); + } + if (failure !== undefined) throw failure; + } + startResources(): Promise { return (this.resourceStartPromise ??= (() => { const contributions = this.options.integration?.resources ?? []; @@ -233,6 +298,7 @@ export class DesktopSupervisor { ...options, ...(this.options.input === 'accessible' ? { disabledInputFeatures: accessibleLaneMaskedFeatures } : {}), application, + ...(this.nativeArtifact ? { artifact: this.nativeArtifact } : {}), nativeDriver: this.target.nativeDriver, endpoint: this.target.endpoint, onStderr: (text) => diff --git a/packages/agentic/desktop-test/src/windowsApplicationOwnership.contract.test.ts b/packages/agentic/desktop-test/src/windowsApplicationOwnership.contract.test.ts new file mode 100644 index 0000000000..8a83bf8421 --- /dev/null +++ b/packages/agentic/desktop-test/src/windowsApplicationOwnership.contract.test.ts @@ -0,0 +1,580 @@ +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, createDesktopDriverClient } from '@fluentui-react-native/desktop-driver'; +import type { ApplicationLease, NativeApplicationIdentity, NativeDriverArtifact } from '@fluentui-react-native/desktop-driver'; +import { describeWindowsService } from '@fluentui-react-native/desktop-driver/authority'; +import { resolveNativeDesktopDriver } from '@fluentui-react-native/desktop-driver/native'; + +import { captureDesktopApplicationIdentity } from './applicationOwnership'; +import { validateDesktopConfig } from './config'; +import type { ResolvedDesktopTarget } from './config'; +import { DesktopRunReporter } from './reporting/collector'; +import { DesktopSupervisor } from './supervisor'; +import { createDesktopWorkloadClaim } from './ownership'; + +jest.setTimeout(300_000); + +const enabled = + process.platform === 'win32' && process.env.FURN_NATIVE_DRIVER_TEST === '1' && process.env.FURN_NATIVE_APPLICATION_OWNERSHIP_TEST === '1'; +const endpoints = ['windows', 'win32'] as const; +type Endpoint = (typeof endpoints)[number]; +const cases: Record[] = []; +const targets = new Map(); +let artifact: NativeDriverArtifact; +let directory: string; + +function nativeTest(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(); + cases.push({ case: name, state: 'passed' }); + } catch (error) { + cases.push({ case: name, state: 'failed', error: error instanceof Error ? error.message : String(error) }); + throw error; + } + }); + if (enabled && !selected) cases.push({ case: name, state: 'not-run', reason: 'Physical-input opt-in was not enabled.' }); +} + +async function waitFor(description: string, read: () => Promise): Promise { + const expires = Date.now() + 90_000; + while (Date.now() < expires) { + const value = await read(); + if (value !== undefined) return value; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`Timed out waiting for ${description}.`); +} + +async function cleanupAll(label: string, cleanups: (() => Promise)[]): Promise { + const failures: unknown[] = []; + for (const cleanup of cleanups) { + try { + await cleanup(); + } catch (error) { + failures.push(error); + } + } + if (failures.length) throw new AggregateError(failures, label); +} + +function host(endpoint: Endpoint): NativeDesktopHost { + return new NativeDesktopHost({ artifact, application: targets.get(endpoint)!.host.application!, endpoint }); +} + +function differentStart(startedAt: string): string { + const fraction = Number(startedAt.slice(-8, -1)); + return `${startedAt.slice(0, -8)}${String(fraction === 9_999_999 ? fraction - 1 : fraction + 1).padStart(7, '0')}Z`; +} + +function registration(endpoint: Endpoint) { + return { endpoint, id: targets.get(endpoint)!.id, launchTimeoutMs: 60_000, platformName: 'windows' } as const; +} + +async function ended(identity: NativeApplicationIdentity): Promise { + try { + const actual = await describeWindowsService({ artifact, pid: identity.processId }); + return actual.startedAt !== identity.processStartedAt; + } catch (error) { + if (error !== null && typeof error === 'object' && 'reason' in error && error.reason === 'exited') return true; + throw error; + } +} + +function supervisor(endpoint: Endpoint, lockDirectory: string, ownership: 'supervisor' | 'external') { + const output = { write: () => true }; + const reporter = new DesktopRunReporter({ artifactsRoot: path.join(directory, 'reports'), output, errorOutput: output, summary: false }); + return { + reporter, + owner: new DesktopSupervisor({ + target: { ...targets.get(endpoint)!, host: { ...targets.get(endpoint)!.host, ownership } }, + reporter, + reportSession: { reporter, delegated: true, environment: {}, close: async () => 0 }, + lockDirectory, + }), + }; +} + +beforeAll(async () => { + if (!enabled) return; + artifact = await resolveNativeDesktopDriver({ platform: 'windows', buildPolicy: 'never' }); + const root = path.join(process.cwd(), 'artifacts', 'desktop'); + fs.mkdirSync(root, { recursive: true }); + directory = fs.mkdtempSync(path.join(root, 'windows-application-ownership-')); + for (const endpoint of endpoints) { + const setting = endpoint === 'windows' ? process.env.FURN_OWNERSHIP_WINDOWS_APPLICATION : process.env.FURN_OWNERSHIP_WIN32_APPLICATION; + if (!setting) throw new Error(`Explicit ${endpoint} application JSON is required for native consumer qualification.`); + const application: unknown = JSON.parse(setting); + validateDesktopConfig({ schemaVersion: 1, targets: { [endpoint]: { host: { kind: 'installed', application } } } }); + targets.set(endpoint, { + endpoint, + id: `f10w-${endpoint}`, + registrationId: `f10w-${endpoint}`, + instanceId: `f10w-consumer-${endpoint}`, + projectRoot: directory, + configPath: path.join(directory, 'desktop.config.mts'), + launchTimeoutMs: 60_000, + nativeDriver: { helperPath: artifact.executablePath }, + host: { + kind: 'installed', + application: application as ResolvedDesktopTarget['host']['application'], + renderer: endpoint === 'windows' ? 'fabric' : 'paper', + }, + }); + } +}); + +afterAll(() => { + if (!directory) return; + fs.writeFileSync( + path.join(directory, 'evidence.json'), + JSON.stringify( + { + recordedAt: new Date().toISOString(), + sourceRevision: spawnSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8', timeout: 5000 }).stdout?.trim(), + helperArtifactId: artifact?.artifactId, + helperSourceDigest: artifact?.sourceDigest, + endpoints: [...targets].map(([endpoint, target]) => ({ + endpoint, + renderer: target.host.renderer, + application: target.host.application, + })), + cases, + }, + null, + 2, + ), + ); +}); + +for (const endpoint of endpoints) { + nativeTest( + `${endpoint}: five supervisor launches retain exact identity, exclude another service, and tear down without leaks`, + async () => { + const incarnations = new Set(); + for (let iteration = 0; iteration < 5; iteration++) { + const { owner, reporter } = supervisor(endpoint, path.join(directory, `${endpoint}-repeat`), 'supervisor'); + const observer = host(endpoint); + let identity: NativeApplicationIdentity | undefined; + try { + await owner.start(); + const client = createDesktopDriverClient({ url: owner.server.url }); + const capabilities = { alwaysMatch: { platformName: 'windows', 'furn:target': targets.get(endpoint)!.id } }; + const first = await client.newSession(capabilities); + const lease = owner.server.sessions.get(first.id).lease; + const { schemaVersion: _schemaVersion, endpoint: _endpoint, ...captured } = captureDesktopApplicationIdentity(lease, endpoint); + identity = captured; + expect(lease).toMatchObject({ + ownership: 'attached', + containment: endpoint === 'windows' ? 'uncontained' : 'job-object', + launchKind: endpoint === 'windows' ? 'packaged-activation' : 'direct-child', + }); + if (endpoint === 'windows') expect(identity.aumid).toBe(targets.get(endpoint)!.host.application!.aumid); + expect(await observer.inspectApplication({ ...registration(endpoint), host: observer })).toEqual(identity); + const windows = await first.getWindowHandles(); + await expect(observer.attach({ ...registration(endpoint), host: observer })).rejects.toMatchObject({ + data: { reason: 'application-lock-busy' }, + }); + await expect(first.getWindowHandles()).resolves.toEqual(windows); + await first.delete(); + expect(await ended(identity)).toBe(false); + const second = await client.newSession(capabilities); + expect(owner.server.sessions.get(second.id).lease).toEqual(lease); + await second.delete(); + const claim = JSON.parse(fs.readFileSync(path.join(directory, `${endpoint}-repeat`, 'interactive.lock'), 'utf8')); + expect(claim.resources).toEqual( + expect.arrayContaining([ + expect.objectContaining({ ownership: 'owned', disposal: 'pending', application: expect.objectContaining(identity) }), + ]), + ); + incarnations.add(JSON.stringify([identity.processId, identity.processStartedAt])); + cases.push({ case: 'repeat-incarnation', endpoint, iteration, identity, containment: lease.containment, state: 'observed' }); + } finally { + await cleanupAll('Repeated-launch qualification cleanup failed.', [ + () => observer.dispose(), + () => owner.close(), + () => reporter.finish(), + ]); + } + expect(identity).toBeDefined(); + await waitFor('exact application teardown', async () => ((await ended(identity!)) ? true : undefined)); + expect(fs.existsSync(path.join(directory, `${endpoint}-repeat`, 'interactive.lock'))).toBe(false); + } + expect(incarnations.size).toBe(5); + }, + ); + + nativeTest(`${endpoint}: native start, session and executable mismatches never acquire the recorded application`, async () => { + const owner = host(endpoint); + let lease: ApplicationLease | undefined; + try { + lease = await owner.launch({ ...registration(endpoint), host: owner }); + const identity = await owner.inspectApplication({ ...registration(endpoint), host: owner }); + const mismatches = [ + ['identity-start-mismatch', { ...identity, processStartedAt: '2026-01-01T00:00:00.0000000Z' }], + ['identity-session-mismatch', { ...identity, interactiveSession: 'windows-session:999999' }], + ['identity-executable-mismatch', { ...identity, executablePath: 'C:\\not-the-recorded-application.exe' }], + ['identity-aumid-mismatch', { ...identity, aumid: 'foreign_package!App' }], + ] as const; + for (const [reason, processIdentity] of mismatches) { + const pinned = new NativeDesktopHost({ + artifact, + endpoint, + application: { ...targets.get(endpoint)!.host.application, processIdentity }, + }); + try { + await expect(pinned.inspectApplication({ ...registration(endpoint), host: pinned })).rejects.toMatchObject({ data: { reason } }); + if (reason === 'identity-start-mismatch') { + await expect(pinned.recoverApplication(processIdentity)).resolves.toBeUndefined(); + } else { + await expect(pinned.recoverApplication(processIdentity)).rejects.toMatchObject({ data: { reason } }); + } + expect(await ended(identity)).toBe(false); + } finally { + await pinned.dispose(); + } + } + } finally { + await cleanupAll('Identity-mismatch qualification cleanup failed.', [ + async () => { + if (lease) await owner.closeApplication(lease); + }, + () => owner.dispose(), + ]); + } + }); + + nativeTest(`${endpoint}: a crashed recoverer retires a reused application journal without signalling its replacement`, async () => { + const lockDirectory = path.join(directory, `${endpoint}-reused-journal`); + const requestPath = path.join(directory, `${endpoint}-recoverer-request.json`); + const readyPath = path.join(directory, `${endpoint}-recoverer-ready.json`); + const filename = path.join(lockDirectory, 'interactive.lock'); + const current = host(endpoint); + let child: ChildProcess | undefined; + let recovererIdentity: Awaited> | undefined; + let lease: ApplicationLease | undefined; + try { + lease = await current.launch({ ...registration(endpoint), host: current }); + const { schemaVersion: _schemaVersion, endpoint: _endpoint, ...identity } = captureDesktopApplicationIdentity(lease, endpoint); + const nativeOwner = await describeWindowsService({ artifact, pid: process.pid }); + const claim = createDesktopWorkloadClaim({ + directory: lockDirectory, + processIdentity: nativeOwner, + runId: 'simulated-prior-incarnation', + targetId: targets.get(endpoint)!.id, + targetInstanceId: targets.get(endpoint)!.instanceId, + }); + const staleApplication = { ...identity, processStartedAt: differentStart(identity.processStartedAt) }; + claim.updateResource({ + id: 'application:simulated-prior-incarnation', + identity: 'simulated-prior-incarnation', + generation: 1, + ownership: 'owned', + readiness: 'ready', + disposal: 'pending', + application: { + ...staleApplication, + endpoint, + leaseId: lease.id, + lifetime: 'supervisor', + containment: lease.containment!, + launchKind: lease.launchKind!, + }, + }); + // Inject stale kernel timestamps, not actual PID recycling. Both live replacement processes stay owned by this test. + const staleOwnerStart = differentStart(nativeOwner.startedAt); + fs.writeFileSync( + filename, + JSON.stringify({ + ...claim.record, + owner: { + ...claim.record.owner, + processStartedAt: staleOwnerStart, + processIdentity: { ...nativeOwner, startedAt: staleOwnerStart }, + }, + }), + ); + fs.writeFileSync( + requestPath, + JSON.stringify({ artifact, lockDirectory, target: targets.get(endpoint), operation: 'interrupt-recovery' }), + { flag: 'wx' }, + ); + child = spawn(process.execPath, [path.join(__dirname, 'windowsOwnershipOwner.contract.cjs'), requestPath, readyPath], { + stdio: ['pipe', 'pipe', 'pipe'], + }); + let childError = ''; + child.stderr!.on('data', (data: Buffer) => { + childError = (childError + data.toString('utf8')).slice(-16_384); + }); + await once(child, 'spawn'); + recovererIdentity = await describeWindowsService({ artifact, pid: child.pid! }); + const ready = await waitFor<{ owner: typeof recovererIdentity; application: NativeApplicationIdentity }>( + 'the live recovery barrier', + async () => { + if (child!.exitCode !== null || child!.signalCode !== null) throw new Error(`Recoverer exited before readiness: ${childError}`); + return fs.existsSync(readyPath) ? JSON.parse(fs.readFileSync(readyPath, 'utf8')) : undefined; + }, + ); + expect(ready.owner).toEqual(recovererIdentity); + expect(ready.application).toEqual(staleApplication); + const contender = supervisor(endpoint, lockDirectory, 'external'); + try { + await expect(contender.owner.start()).rejects.toThrow(/recovery.*busy/); + } finally { + await cleanupAll('Live-recoverer qualification cleanup failed.', [ + () => contender.owner.close(), + () => contender.reporter.finish(), + ]); + } + const exited = once(child, 'exit'); + await stopExactProcess(recovererIdentity); + await exited; + const next = supervisor(endpoint, lockDirectory, 'external'); + try { + await next.owner.start(); + const replacementClaim = JSON.parse(fs.readFileSync(filename, 'utf8')); + expect(replacementClaim.owner.id).not.toBe(claim.record.owner.id); + expect(fs.existsSync(`${filename}.recovery-v2`)).toBe(false); + expect(await current.inspectApplication({ ...registration(endpoint), host: current })).toEqual(identity); + expect(await ended(identity)).toBe(false); + } finally { + await cleanupAll('Reused-journal qualification cleanup failed.', [() => next.owner.close(), () => next.reporter.finish()]); + } + expect(fs.existsSync(filename)).toBe(false); + cases.push({ + case: 'reused-application-and-recoverer-crash', + endpoint, + identity, + recordedApplication: staleApplication, + recoverer: recovererIdentity, + pidReuse: 'injected-recorded-start-mismatch-not-kernel-recycling', + state: 'observed', + }); + } finally { + await cleanupAll('Reused-journal fixture cleanup failed.', [ + async () => { + if (child) await stopChild(child, recovererIdentity); + }, + async () => { + if (lease) await current.closeApplication(lease); + }, + () => current.dispose(), + ]); + } + }); + + nativeTest(`${endpoint}: crashed-owner journal fences a live owner and recovers only its acquired exact application`, async () => { + const lockDirectory = path.join(directory, `${endpoint}-crash`); + const requestPath = path.join(directory, `${endpoint}-owner-request.json`); + const readyPath = path.join(directory, `${endpoint}-owner-ready.json`); + fs.writeFileSync(requestPath, JSON.stringify({ artifact, lockDirectory, target: targets.get(endpoint) }), { flag: 'wx' }); + const child = spawn(process.execPath, [path.join(__dirname, 'windowsOwnershipOwner.contract.cjs'), requestPath, readyPath], { + stdio: ['pipe', 'pipe', 'pipe'], + }); + let childError = ''; + child.stderr!.on('data', (data: Buffer) => { + childError = (childError + data.toString('utf8')).slice(-16_384); + }); + let ready: + | { + application: NativeApplicationIdentity; + helper: Awaited>; + owner: Awaited>; + } + | undefined; + let spawnedOwner: Awaited> | undefined; + try { + await once(child, 'spawn'); + spawnedOwner = await describeWindowsService({ artifact, pid: child.pid! }); + ready = await waitFor('the journaled native owner', async () => { + if (child.exitCode !== null || child.signalCode !== null) throw new Error(`Native owner exited before readiness: ${childError}`); + return fs.existsSync(readyPath) ? JSON.parse(fs.readFileSync(readyPath, 'utf8')) : undefined; + }); + expect(ready.owner).toEqual(spawnedOwner); + await stopExactProcess(ready.helper); + if (endpoint === 'windows') { + expect(await ended(ready.application)).toBe(false); + } else { + await waitFor('the crashed helper Job Object to drain', async () => ((await ended(ready!.application)) ? true : undefined)); + } + const live = supervisor(endpoint, lockDirectory, 'external'); + try { + await expect(live.owner.start()).rejects.toThrow(/live or unknown/); + } finally { + await cleanupAll('Live-owner qualification cleanup failed.', [() => live.owner.close(), () => live.reporter.finish()]); + } + expect(child.pid).toBe(ready.owner.pid); + const ownerExited = once(child, 'exit'); + await stopExactProcess(ready.owner); + await ownerExited; + const next = supervisor(endpoint, lockDirectory, 'external'); + try { + await next.owner.start(); + expect(await ended(ready.application)).toBe(true); + } finally { + await cleanupAll('Replacement-owner qualification cleanup failed.', [() => next.owner.close(), () => next.reporter.finish()]); + } + expect(fs.existsSync(path.join(lockDirectory, 'interactive.lock'))).toBe(false); + cases.push({ + case: 'crash-recovery', + endpoint, + identity: ready.application, + owner: ready.owner, + helper: ready.helper, + state: 'observed', + }); + } finally { + await cleanupAll('Crashed-owner qualification cleanup failed.', [ + () => stopChild(child, spawnedOwner), + async () => { + if (ready && !(await ended(ready.application))) { + const cleanup = host(endpoint); + try { + await cleanup.recoverApplication(ready.application); + } finally { + await cleanup.dispose(); + } + } + }, + ]); + } + }); +} + +for (const endpoint of endpoints) { + nativeTest(`${endpoint}: concurrent launches preserve each exact winning incarnation and reject ambiguous inspection`, async () => { + for (let iteration = 0; iteration < 3; iteration++) { + const contenders = [host(endpoint), host(endpoint)]; + const observers: NativeDesktopHost[] = []; + const identities: NativeApplicationIdentity[] = []; + try { + await Promise.all(contenders.map((native) => native.probe())); + const attempts = await Promise.allSettled(contenders.map((native) => native.launch({ ...registration(endpoint), host: native }))); + const winners = attempts.flatMap((attempt, index) => (attempt.status === 'fulfilled' ? [{ lease: attempt.value, index }] : [])); + expect(winners.length).toBeGreaterThan(0); + for (const winner of winners) { + const { + schemaVersion: _schemaVersion, + endpoint: _endpoint, + ...identity + } = captureDesktopApplicationIdentity(winner.lease, endpoint); + identities.push(identity); + } + expect(new Set(identities.map((identity) => JSON.stringify([identity.processId, identity.processStartedAt]))).size).toBe( + winners.length, + ); + for (const [index, attempt] of attempts.entries()) { + if (attempt.status === 'rejected') { + expect(attempt.reason).toMatchObject({ + data: { reason: expect.stringMatching(/^application-(lock-busy|preexisting)$/) }, + }); + await contenders[index].dispose(); + } + } + const unpinned = host(endpoint); + observers.push(unpinned); + if (winners.length > 1) { + await expect(unpinned.inspectApplication({ ...registration(endpoint), host: unpinned })).rejects.toMatchObject({ + data: { reason: 'application-identity-ambiguous' }, + }); + } + for (const identity of identities) { + const pinned = new NativeDesktopHost({ + artifact, + endpoint, + application: { ...targets.get(endpoint)!.host.application, processIdentity: identity }, + }); + observers.push(pinned); + expect(await ended(identity)).toBe(false); + expect(await pinned.inspectApplication({ ...registration(endpoint), host: pinned })).toEqual(identity); + await expect(pinned.attach({ ...registration(endpoint), host: pinned })).rejects.toMatchObject({ + data: { reason: 'application-lock-busy' }, + }); + } + await contenders[winners[0].index].closeApplication(winners[0].lease); + await waitFor('one exact concurrent incarnation to exit', async () => ((await ended(identities[0])) ? true : undefined)); + for (const identity of identities.slice(1)) expect(await ended(identity)).toBe(false); + cases.push({ + case: 'concurrent-launch', + endpoint, + iteration, + identities, + rejectedContenders: attempts.filter((attempt) => attempt.status === 'rejected').length, + state: 'observed', + }); + } finally { + await cleanupAll( + 'Concurrent launch qualification cleanup failed.', + [...observers, ...contenders].reverse().map((native) => () => native.dispose()), + ); + } + for (const identity of identities) { + await waitFor('every exact concurrent incarnation to exit', async () => ((await ended(identity)) ? true : undefined)); + } + } + }); +} + +nativeTest( + 'Windows Fabric and Win32 Paper: independent helpers share the physical input mutex', + async () => { + const hosts = endpoints.map((endpoint) => host(endpoint)); + const actions = [{ type: 'key' as const, id: 'ownership-shift', actions: [{ type: 'keyDown' as const, value: '\uE008' }] }]; + try { + for (const [index, endpoint] of endpoints.entries()) await hosts[index].launch({ ...registration(endpoint), host: hosts[index] }); + await hosts[0].performActions(actions); + await expect(hosts[1].performActions(actions, AbortSignal.timeout(300))).rejects.toMatchObject({ + name: 'AbortError', + effect: 'none', + }); + await hosts[0].releaseActions(); + await hosts[1].performActions(actions); + await hosts[1].releaseActions(); + } finally { + await cleanupAll( + 'Physical mutex qualification cleanup failed.', + hosts.reverse().map((native) => () => native.dispose()), + ); + } + }, + true, +); + +async function stopExactProcess(identity: Awaited>): Promise { + expect(Number.isSafeInteger(identity.pid) && identity.pid > 0).toBe(true); + expect(await describeWindowsService({ artifact, pid: identity.pid })).toEqual(identity); + // Each invocation has one literal numeric PID; no name search, pipeline, wildcard or interpolated script. + const result = spawnSync( + 'pwsh', + ['-NoProfile', '-NonInteractive', '-Command', 'Stop-Process', '-Id', String(identity.pid), '-ErrorAction', 'Stop'], + { encoding: 'utf8', timeout: 30_000, windowsHide: true }, + ); + if (result.error || result.status !== 0) throw result.error ?? new Error('The identity-verified process stop failed.'); +} + +async function stopChild(child: ChildProcess, identity?: Awaited>): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + const exited = once(child, 'exit'); + child.stdin?.end(); + let timeout: ReturnType; + try { + await Promise.race([ + exited, + new Promise((resolve) => { + timeout = setTimeout(resolve, 10_000); + }), + ]); + } finally { + clearTimeout(timeout!); + } + if (child.exitCode === null && child.signalCode === null) { + if (!identity || child.pid !== identity.pid) throw new Error('Native owner identity was not captured; cleanup remains fenced.'); + await stopExactProcess(identity); + await exited; + } +} diff --git a/packages/agentic/desktop-test/src/windowsOwnership.test.ts b/packages/agentic/desktop-test/src/windowsOwnership.test.ts new file mode 100644 index 0000000000..aeb70f6195 --- /dev/null +++ b/packages/agentic/desktop-test/src/windowsOwnership.test.ts @@ -0,0 +1,587 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import type { ApplicationLease } from '@fluentui-react-native/desktop-driver'; + +import { captureDesktopApplicationIdentity, desktopApplicationIdentityDescriptor } from './applicationOwnership'; +import { createDesktopWorkloadClaim, recoverWindowsDesktopWorkloadClaim } from './ownership'; +import type { DesktopWorkloadOwnerIdentity } from './ownership'; +import type { DesktopResourceRecord } from './resources'; +import { defineDesktopConfig } from './config'; +import type { ResolvedDesktopTarget } from './config'; +import { resolveDesktopApplication } from './adapters'; + +const exact = { + processId: 456, + processStartedAt: '2026-11-12T00:00:00.1234567Z', + executablePath: 'C:\\apps\\ReactApp.exe', + aumid: 'package_family!App', + interactiveSession: 'windows-session:2', +}; +const owner = { + platform: 'windows' as const, + pid: process.pid, + startedAt: '2026-11-12T00:00:00.7654321Z', + executablePath: process.execPath, + interactiveSession: 'windows-session:2', +}; + +describe('Windows exact identity and survivor recovery', () => { + let directory: string; + const options = () => ({ + directory, + endpoint: 'windows' as const, + targetId: 'packaged', + targetInstanceId: 'package-instance', + processIdentity: owner, + }); + const filename = () => path.join(directory, 'interactive.lock'); + + beforeEach(() => { + directory = fs.mkdtempSync(path.join(process.cwd(), '.windows-ownership-test-')); + }); + afterEach(() => { + jest.restoreAllMocks(); + fs.rmSync(directory, { recursive: true, force: true }); + }); + + function survivor(extra: readonly DesktopResourceRecord[] = []) { + const claim = createDesktopWorkloadClaim({ ...options(), runId: 'crashed-run', processIdentity: owner }); + claim.updateResource({ + id: 'application:1', + identity: 'exact-application-incarnation', + generation: 1, + ownership: 'owned', + readiness: 'ready', + disposal: 'pending', + application: { + ...exact, + endpoint: 'windows', + lifetime: 'session', + containment: 'uncontained', + launchKind: 'packaged-activation', + leaseId: 'owned-lease', + }, + }); + for (const resource of extra) claim.updateResource(resource); + return claim; + } + + test.each(['windows', 'win32'] as const)('captures %s identity without rounding away native start/session evidence', (endpoint) => { + const lease = { ...exact, id: 'native', ownership: 'launched', containment: 'uncontained' } as ApplicationLease; + const identity = captureDesktopApplicationIdentity(lease, endpoint); + expect(identity).toEqual({ schemaVersion: 1, endpoint, ...exact }); + expect(desktopApplicationIdentityDescriptor(identity, { aumid: exact.aumid, windowTitle: 'Fixture' })).toEqual({ + aumid: exact.aumid, + windowTitle: 'Fixture', + processIdentity: exact, + }); + expect(() => captureDesktopApplicationIdentity({ ...lease, processStartedAt: '2026-11-12T00:00:00.123Z' }, endpoint)).toThrow(/native/); + expect(() => captureDesktopApplicationIdentity({ ...lease, interactiveSession: undefined }, endpoint)).toThrow(/session/); + expect(() => captureDesktopApplicationIdentity({ ...lease, aumid: '' }, endpoint)).toThrow(/identity/); + expect(() => captureDesktopApplicationIdentity({ ...lease, executablePath: `C:\\${'a'.repeat(4096)}.exe` }, endpoint)).toThrow( + /identity/, + ); + expect(() => desktopApplicationIdentityDescriptor(identity, { executablePath: 'C:\\other.exe', windowTitle: 'Fixture' })).toThrow( + /identity/, + ); + }); + + test('the next run cleans a journaled packaged survivor only after proving its owner exited', async () => { + survivor(); + const recoverApplication = jest.fn(async () => undefined); + await expect( + recoverWindowsDesktopWorkloadClaim({ + ...options(), + probeOwner: async () => ({ state: 'exited' }), + recoverApplication, + }), + ).resolves.toEqual({ state: 'recovered', applications: 1 }); + expect(recoverApplication).toHaveBeenCalledWith(exact); + expect(fs.existsSync(filename())).toBe(false); + const next = createDesktopWorkloadClaim({ ...options(), runId: 'next-run', processIdentity: owner }); + next.release(); + }); + + test('a live owner is probed without publishing any recovery barrier', async () => { + const claim = survivor(); + let barrierDuringProbe = false; + await expect( + recoverWindowsDesktopWorkloadClaim({ + ...options(), + probeOwner: async () => { + barrierDuringProbe = ['.recovery', '.recovery-v2'].some((suffix) => fs.existsSync(filename() + suffix)); + return { state: 'alive', processStartedAt: claim.record.owner.processStartedAt! }; + }, + recoverApplication: jest.fn(async () => undefined), + }), + ).rejects.toThrow(/owner.*live|fenced/); + expect(barrierDuringProbe).toBe(false); + }); + + test('a foreign target never creates even a temporary recovery barrier', async () => { + survivor(); + const mkdir = jest.spyOn(fs, 'mkdirSync'); + const probeOwner = jest.fn(async () => ({ state: 'exited' as const })); + await expect( + recoverWindowsDesktopWorkloadClaim({ + ...options(), + targetInstanceId: 'foreign-target', + probeOwner, + recoverApplication: jest.fn(async () => undefined), + }), + ).rejects.toThrow(/different target/); + expect(probeOwner).not.toHaveBeenCalled(); + expect(mkdir).not.toHaveBeenCalled(); + }); + + test('claim replacement while acquiring the barrier never inherits recovery authority', async () => { + survivor(); + const replacement = JSON.stringify({ ...JSON.parse(fs.readFileSync(filename(), 'utf8')), state: 'active' }); + const original = fs.mkdirSync; + jest.spyOn(fs, 'mkdirSync').mockImplementation((root, attributes) => { + const result = original(root, attributes); + if (root === `${filename()}.recovery` || root === `${filename()}.recovery-v2`) { + fs.writeFileSync(filename(), replacement); + } + return result; + }); + const recoverApplication = jest.fn(async () => undefined); + await expect( + recoverWindowsDesktopWorkloadClaim({ + ...options(), + probeOwner: async () => ({ state: 'exited' }), + recoverApplication, + }), + ).rejects.toThrow(/ownership changed/); + expect(recoverApplication).not.toHaveBeenCalled(); + expect(fs.readFileSync(filename(), 'utf8')).toBe(replacement); + }); + + async function interruptBarrierCleanup(removeClaim = false): Promise { + const original = fs.rmdirSync; + const interrupted = jest.spyOn(fs, 'rmdirSync').mockImplementation((root, attributes) => { + if (String(root).startsWith(`${filename()}.recovery`)) throw new Error('recoverer crashed before barrier removal'); + return original(root, attributes); + }); + try { + await expect( + recoverWindowsDesktopWorkloadClaim({ + ...options(), + probeOwner: async () => ({ state: 'exited' }), + recoverApplication: async () => { + if (!removeClaim) throw new Error('recoverer crashed during application cleanup'); + }, + }), + ).rejects.toThrow(/recovery|recoverer|barrier/i); + } finally { + interrupted.mockRestore(); + } + } + + test.each(['exited', 'pid-reused'] as const)('resumes an interrupted barrier whose exact recoverer is %s', async (state) => { + const claim = survivor(); + await interruptBarrierCleanup(); + const recoverApplication = jest.fn(async () => undefined); + await expect( + recoverWindowsDesktopWorkloadClaim({ + ...options(), + probeOwner: async (candidate) => + candidate.id === claim.record.owner.id || state === 'exited' + ? { state: 'exited' } + : { state: 'alive', processStartedAt: '2026-11-12T00:00:00.7654322Z' }, + recoverApplication, + }), + ).resolves.toEqual({ state: 'recovered', applications: 1 }); + expect(recoverApplication).toHaveBeenCalledWith(exact); + expect(fs.existsSync(filename())).toBe(false); + expect(fs.existsSync(`${filename()}.recovery-v2`)).toBe(false); + }); + + test.each(['live', 'unknown'] as const)('never removes an interrupted barrier with a %s recoverer', async (state) => { + const claim = survivor(); + await interruptBarrierCleanup(); + const before = fs.readFileSync(filename(), 'utf8'); + const recoverApplication = jest.fn(async () => undefined); + const probeOwner = jest.fn(async (candidate: DesktopWorkloadOwnerIdentity) => + candidate.id === claim.record.owner.id + ? { state: 'exited' as const } + : state === 'live' + ? { state: 'alive' as const, processStartedAt: owner.startedAt } + : { state: 'unknown' as const }, + ); + await expect(recoverWindowsDesktopWorkloadClaim({ ...options(), probeOwner, recoverApplication })).rejects.toThrow( + /recovery.*busy|recoverer.*live|recoverer.*unknown/i, + ); + expect(probeOwner).toHaveBeenCalledTimes(2); + expect(recoverApplication).not.toHaveBeenCalled(); + expect(fs.readFileSync(filename(), 'utf8')).toBe(before); + }); + + test('a dead-recoverer probe never deletes a replacement barrier or dispatches application cleanup', async () => { + const claim = survivor(); + await interruptBarrierCleanup(); + const root = `${filename()}.recovery-v2`; + const recoverApplication = jest.fn(async () => undefined); + await expect( + recoverWindowsDesktopWorkloadClaim({ + ...options(), + probeOwner: async (candidate) => { + if (candidate.id !== claim.record.owner.id) { + fs.renameSync(root, `${filename()}.old-recovery`); + fs.mkdirSync(root); + } + return { state: 'exited' }; + }, + recoverApplication, + }), + ).rejects.toThrow(/barrier ownership changed/); + expect(fs.existsSync(root)).toBe(true); + expect(fs.existsSync(filename())).toBe(true); + expect(recoverApplication).not.toHaveBeenCalled(); + }); + + test('retires a dead recoverer barrier after the exact claim was already removed', async () => { + survivor(); + await interruptBarrierCleanup(true); + expect(fs.existsSync(filename())).toBe(false); + const recoverApplication = jest.fn(async () => undefined); + await expect( + recoverWindowsDesktopWorkloadClaim({ ...options(), probeOwner: async () => ({ state: 'exited' }), recoverApplication }), + ).resolves.toEqual({ state: 'absent' }); + expect(recoverApplication).not.toHaveBeenCalled(); + const next = createDesktopWorkloadClaim({ ...options(), runId: 'after-interrupted-recovery', processIdentity: owner }); + next.release(); + }); + + test('unknown versioned barrier ownership still fences an absent claim and new workload', async () => { + const barrier = `${filename()}.recovery-v2`; + fs.mkdirSync(barrier); + fs.mkdirSync(path.join(barrier, 'unknown-owner')); + await expect( + recoverWindowsDesktopWorkloadClaim({ + ...options(), + probeOwner: async () => ({ state: 'exited' }), + recoverApplication: jest.fn(async () => undefined), + }), + ).rejects.toThrow(/recovery.*busy|barrier.*owner|fenced/i); + expect(() => createDesktopWorkloadClaim({ ...options(), runId: 'cannot-bypass-versioned-barrier' })).toThrow(/recovery.*busy/); + expect(fs.existsSync(barrier)).toBe(true); + }); + + test('an empty versioned barrier represents interrupted publication, never application recovery authority', async () => { + const barrier = `${filename()}.recovery-v2`; + fs.mkdirSync(barrier); + const recoverApplication = jest.fn(async () => undefined); + await expect( + recoverWindowsDesktopWorkloadClaim({ ...options(), probeOwner: async () => ({ state: 'exited' }), recoverApplication }), + ).resolves.toEqual({ state: 'absent' }); + expect(recoverApplication).not.toHaveBeenCalled(); + expect(fs.existsSync(barrier)).toBe(false); + }); + + test('an interrupted recovery barrier still fences an absent claim', async () => { + fs.mkdirSync(`${filename()}.recovery`); + const recoverApplication = jest.fn(async () => undefined); + await expect( + recoverWindowsDesktopWorkloadClaim({ + ...options(), + probeOwner: async () => ({ state: 'exited' }), + recoverApplication, + }), + ).rejects.toThrow(/recovery.*busy|interrupted recovery/); + expect(recoverApplication).not.toHaveBeenCalled(); + expect(fs.existsSync(`${filename()}.recovery`)).toBe(true); + }); + + test('a new workload cannot bypass an interrupted recovery barrier after claim removal', () => { + fs.mkdirSync(`${filename()}.recovery`); + expect(() => createDesktopWorkloadClaim({ ...options(), runId: 'replacement', processIdentity: owner })).toThrow( + /recovery.*busy|interrupted recovery/, + ); + expect(fs.existsSync(filename())).toBe(false); + }); + + test.each([ + ['direct-child', 'uncontained'], + ['packaged-activation', 'job-object'], + ] as const)('inconsistent %s / %s containment never authorizes application recovery', async (launchKind, containment) => { + const claim = survivor(); + const resource = claim.record.resources[0]; + claim.updateResource({ ...resource, application: { ...resource.application!, launchKind, containment } }); + const recoverApplication = jest.fn(async () => undefined); + await expect( + recoverWindowsDesktopWorkloadClaim({ + ...options(), + probeOwner: async () => ({ state: 'exited' }), + recoverApplication, + }), + ).rejects.toThrow(/Windows ownership|fenced/); + expect(recoverApplication).not.toHaveBeenCalled(); + expect(fs.existsSync(filename())).toBe(true); + }); + + test('an unrecognized owner-probe state never implies exit', async () => { + survivor(); + const recoverApplication = jest.fn(async () => undefined); + await expect( + recoverWindowsDesktopWorkloadClaim({ + ...options(), + probeOwner: async () => ({ state: 'unexpected' }) as never, + recoverApplication, + }), + ).rejects.toThrow(/owner.*unknown|fenced/); + expect(recoverApplication).not.toHaveBeenCalled(); + }); + + test.each([ + 'live', + 'unknown', + 'foreign-target', + 'unverified', + 'invalid-owner-start', + 'pending-identity', + 'unleased-identity', + 'unknown-resource', + 'unknown-effect', + ])('keeps %s evidence fenced without signaling an application', async (kind) => { + const claim = survivor(); + const record = JSON.parse(fs.readFileSync(filename(), 'utf8')); + if (kind === 'foreign-target') record.owner.targetInstanceId = 'another-target'; + if (kind === 'unverified') delete record.owner.processIdentity; + if (kind === 'invalid-owner-start') { + record.owner.processStartedAt = '2026-99-12T00:00:00.7654321Z'; + record.owner.processIdentity.startedAt = record.owner.processStartedAt; + } + if (kind === 'pending-identity') delete record.resources[0].application.processStartedAt; + if (kind === 'unleased-identity') delete record.resources[0].application.leaseId; + if (kind === 'unknown-resource') + record.resources.push({ + id: 'metro', + identity: 'unknown-process', + generation: 1, + ownership: 'owned', + readiness: 'ready', + disposal: 'pending', + }); + if (kind === 'unknown-effect') + record.unresolvedEffects.push({ + id: 'native-effect', + resourceId: 'application:1', + resourceIdentity: 'exact-application-incarnation', + }); + fs.writeFileSync(filename(), JSON.stringify(record)); + const before = fs.readFileSync(filename(), 'utf8'); + const recoverApplication = jest.fn(async () => undefined); + await expect( + recoverWindowsDesktopWorkloadClaim({ + ...options(), + probeOwner: async () => + kind === 'live' + ? { state: 'alive', processStartedAt: claim.record.owner.processStartedAt! } + : { state: kind === 'unknown' ? 'unknown' : 'exited' }, + recoverApplication, + }), + ).rejects.toThrow(/fenced|owner|identity|resource|effect|target|claim/i); + expect(recoverApplication).not.toHaveBeenCalled(); + expect(fs.readFileSync(filename(), 'utf8')).toBe(before); + }); + + test('a reused owner PID permits exact application recovery, not a PID-only termination', async () => { + survivor(); + const recoverApplication = jest.fn(async () => undefined); + await expect( + recoverWindowsDesktopWorkloadClaim({ + ...options(), + probeOwner: async () => ({ state: 'alive', processStartedAt: '2026-11-12T00:00:00.7654322Z' }), + recoverApplication, + }), + ).resolves.toMatchObject({ state: 'recovered' }); + expect(recoverApplication).toHaveBeenCalledWith(exact); + }); + + test.each(['not-a-native-start', '2026-11-12T00:00:00.123Z', '2026-99-12T00:00:00.1234567Z'])( + 'an invalid owner probe %s never authorizes recovery as PID reuse', + async (processStartedAt) => { + survivor(); + const recoverApplication = jest.fn(async () => undefined); + await expect( + recoverWindowsDesktopWorkloadClaim({ + ...options(), + probeOwner: async () => ({ state: 'alive', processStartedAt }), + recoverApplication, + }), + ).rejects.toThrow(/owner.*unknown|fenced/); + expect(recoverApplication).not.toHaveBeenCalled(); + expect(fs.existsSync(filename())).toBe(true); + }, + ); + + test('partial application recovery persists exact progress without reclaiming an unsettled survivor', async () => { + const claim = survivor(); + claim.updateResource({ + ...claim.record.resources[0], + id: 'application:2', + identity: 'second-incarnation', + application: { ...claim.record.resources[0].application!, leaseId: 'second-lease', processId: 457 }, + }); + const recoverApplication = jest.fn(async (identity) => { + if (identity.processId === 457) throw new Error('application-lock-busy'); + }); + const recovery = { ...options(), probeOwner: async () => ({ state: 'exited' as const }), recoverApplication }; + await expect(recoverWindowsDesktopWorkloadClaim(recovery)).rejects.toThrow('application-lock-busy'); + expect(JSON.parse(fs.readFileSync(filename(), 'utf8')).resources.map(({ disposal }) => disposal)).toEqual(['settled', 'pending']); + recoverApplication.mockResolvedValue(undefined); + await expect(recoverWindowsDesktopWorkloadClaim(recovery)).resolves.toEqual({ state: 'recovered', applications: 1 }); + expect(recoverApplication.mock.calls.map(([identity]) => identity.processId)).toEqual([456, 457, 457]); + }); + + test('the exited owner proves its in-process WebDriver listener ended, not an arbitrary owned service', async () => { + survivor([ + { + id: 'webdriver', + identity: 'driver-service:packaged:package-instance', + generation: 1, + ownership: 'owned', + readiness: 'ready', + disposal: 'pending', + }, + ]); + await expect( + recoverWindowsDesktopWorkloadClaim({ + ...options(), + probeOwner: async () => ({ state: 'exited' }), + recoverApplication: async () => undefined, + }), + ).resolves.toEqual({ state: 'recovered', applications: 1 }); + }); + + test('a failed exact recovery preserves the original process identity and claim', async () => { + survivor(); + const before = fs.readFileSync(filename(), 'utf8'); + await expect( + recoverWindowsDesktopWorkloadClaim({ + ...options(), + probeOwner: async () => ({ state: 'exited' }), + recoverApplication: async () => { + throw new Error('application-identity-mismatch'); + }, + }), + ).rejects.toThrow('application-identity-mismatch'); + expect(fs.readFileSync(filename(), 'utf8')).toBe(before); + }); + + test('barrier replacement preserves the exact recovery failure as well as the foreign barrier', async () => { + survivor(); + const failure = new Error('application-recovery-unsettled'); + let barrier = `${filename()}.recovery`; + let result: unknown; + try { + await recoverWindowsDesktopWorkloadClaim({ + ...options(), + probeOwner: async () => ({ state: 'exited' }), + recoverApplication: async () => { + if (fs.existsSync(`${filename()}.recovery-v2`)) barrier = `${filename()}.recovery-v2`; + fs.renameSync(barrier, `${filename()}.old-recovery`); + fs.mkdirSync(barrier); + throw failure; + }, + }); + } catch (error) { + result = error; + } + expect(result).toBeInstanceOf(AggregateError); + expect((result as AggregateError).errors).toContain(failure); + expect((result as AggregateError).errors).toContainEqual( + expect.objectContaining({ message: expect.stringMatching(/barrier ownership changed/) }), + ); + expect(fs.existsSync(barrier)).toBe(true); + expect(fs.existsSync(filename())).toBe(true); + }); + + test('concurrent reclaimers and a replaced claim never inherit cleanup authority', async () => { + const claim = survivor(); + let finish!: () => void; + const entered = new Promise((resolve) => (finish = resolve)); + let dispatch!: () => void; + const dispatched = new Promise((resolve) => (dispatch = resolve)); + const recovery = { + ...options(), + probeOwner: async (candidate: DesktopWorkloadOwnerIdentity) => + candidate.id === claim.record.owner.id + ? { state: 'exited' as const } + : { state: 'alive' as const, processStartedAt: owner.startedAt }, + recoverApplication: async () => { + dispatch(); + await entered; + }, + }; + const first = recoverWindowsDesktopWorkloadClaim(recovery); + await dispatched; + await expect(recoverWindowsDesktopWorkloadClaim(recovery)).rejects.toThrow(/recovery.*busy|another.*recover/i); + const replacement = '{"replacement":"foreign-owner"}'; + fs.writeFileSync(filename(), replacement); + finish(); + await expect(first).rejects.toThrow(/ownership changed/); + expect(fs.readFileSync(filename(), 'utf8')).toBe(replacement); + }); + + test('attached applications remain external and are never sent to the recovery terminator', async () => { + const claim = createDesktopWorkloadClaim({ ...options(), runId: 'external-run', processIdentity: owner }); + claim.updateResource({ + id: 'external:1', + identity: 'borrowed-incarnation', + generation: 1, + ownership: 'borrowed', + readiness: 'ready', + disposal: 'not-owned', + application: { ...exact, endpoint: 'windows', lifetime: 'external', containment: 'external' }, + }); + const recoverApplication = jest.fn(async () => undefined); + await expect( + recoverWindowsDesktopWorkloadClaim({ ...options(), probeOwner: async () => ({ state: 'exited' }), recoverApplication }), + ).resolves.toEqual({ state: 'recovered', applications: 0 }); + expect(recoverApplication).not.toHaveBeenCalled(); + }); + + test.each(['windows', 'win32'] as const)('accepts supervisor ownership and an exact %s pin in configuration', (endpoint) => { + const target = { + endpoint, + host: { + kind: 'installed' as const, + ownership: 'supervisor' as const, + application: { processIdentity: exact, windowTitle: 'Fixture' }, + }, + }; + expect(() => defineDesktopConfig({ schemaVersion: 1, targets: { fixture: target } })).not.toThrow(); + for (const processIdentity of [ + { ...exact, interactiveSession: 'macos-session:2' }, + { ...exact, executablePath: 'ReactTest.exe' }, + { ...exact, processStartedAt: '2026-11-12T00:00:00.123456Z' }, + { processId: exact.processId, processStartedAt: exact.processStartedAt }, + ]) { + expect(() => + defineDesktopConfig({ + schemaVersion: 1, + targets: { fixture: { ...target, host: { ...target.host, application: { processIdentity, windowTitle: 'Fixture' } } } }, + }), + ).toThrow(/identity|session|native|timestamp|executable/i); + } + }); + + test.each(['windows', 'win32'] as const)( + 'resolves an installed exact %s pin without re-discovering another process', + async (endpoint) => { + const target: ResolvedDesktopTarget = { + endpoint, + id: 'exact-external', + registrationId: 'exact-external', + instanceId: 'external-instance', + projectRoot: directory, + configPath: path.join(directory, 'desktop.config.mts'), + host: { kind: 'installed', ownership: 'external', application: { processIdentity: exact, windowTitle: 'Fixture' } }, + }; + await expect(resolveDesktopApplication(target)).resolves.toEqual(target.host.application); + }, + ); +}); diff --git a/packages/agentic/desktop-test/src/windowsOwnershipOwner.contract.cjs b/packages/agentic/desktop-test/src/windowsOwnershipOwner.contract.cjs new file mode 100644 index 0000000000..1da9d26f3e --- /dev/null +++ b/packages/agentic/desktop-test/src/windowsOwnershipOwner.contract.cjs @@ -0,0 +1,108 @@ +const fs = require('node:fs'); + +function publishReady(readyPath, value) { + const pending = `${readyPath}.pending`; + fs.writeFileSync(pending, JSON.stringify(value), { flag: 'wx' }); + fs.renameSync(pending, readyPath); +} + +async function waitForParent() { + process.stdin.resume(); + await new Promise((resolve) => process.stdin.once('end', resolve)); +} + +async function main() { + const [requestPath, readyPath] = process.argv.slice(2); + const request = JSON.parse(fs.readFileSync(requestPath, 'utf8')); + const { NativeDesktopHost } = await import('@fluentui-react-native/desktop-driver'); + const { describeWindowsService } = await import('@fluentui-react-native/desktop-driver/authority'); + const { createDesktopApplicationHost, captureDesktopApplicationIdentity } = + await import('@fluentui-react-native/desktop-test/application-ownership'); + const { createDesktopWorkloadClaim, recoverWindowsDesktopWorkloadClaim } = await import('@fluentui-react-native/desktop-test/ownership'); + const owner = await describeWindowsService({ artifact: request.artifact, pid: process.pid }); + if (request.operation === 'interrupt-recovery') { + const native = new NativeDesktopHost({ + artifact: request.artifact, + application: request.target.host.application, + endpoint: request.target.endpoint, + }); + try { + await recoverWindowsDesktopWorkloadClaim({ + directory: request.lockDirectory, + endpoint: request.target.endpoint, + targetId: request.target.id, + targetInstanceId: request.target.instanceId, + processIdentity: owner, + probeOwner: async (candidate) => { + try { + const described = await describeWindowsService({ artifact: request.artifact, pid: candidate.processId }); + return { state: 'alive', processStartedAt: described.startedAt }; + } catch (error) { + return error !== null && typeof error === 'object' && 'reason' in error && error.reason === 'exited' + ? { state: 'exited' } + : { state: 'unknown' }; + } + }, + recoverApplication: async (application) => { + publishReady(readyPath, { owner, application }); + await waitForParent(); + await native.recoverApplication(application); + }, + }); + } finally { + await native.dispose(); + } + return; + } + const claim = createDesktopWorkloadClaim({ + directory: request.lockDirectory, + processIdentity: owner, + runId: 'native-crash-owner', + targetId: request.target.id, + targetInstanceId: request.target.instanceId, + }); + const native = new NativeDesktopHost({ + artifact: request.artifact, + application: request.target.host.application, + endpoint: request.target.endpoint, + }); + /** @type {Omit} */ + const registration = { + endpoint: request.target.endpoint, + id: request.target.id, + launchTimeoutMs: request.target.launchTimeoutMs, + platformName: 'windows', + }; + let host; + try { + host = await createDesktopApplicationHost(native, { + lifetime: 'supervisor', + onResource: (resource) => claim.updateResource(resource), + resourcePrefix: 'native-crash-owner', + target: registration, + }); + const lease = await host.launch({ ...registration, host }); + const { + schemaVersion: _schemaVersion, + endpoint: _endpoint, + ...application + } = captureDesktopApplicationIdentity(lease, request.target.endpoint); + // Contract-only access to the child we created; the parent re-verifies its kernel identity before fault injection. + const helperOwner = /** @type {{ processPromise: Promise<{ child: import('node:child_process').ChildProcess }> }} */ ( + /** @type {unknown} */ (native) + ); + const helperProcess = await helperOwner.processPromise; + if (!helperProcess.child.pid) throw new Error('The native owner has no recorded helper PID.'); + const helper = await describeWindowsService({ artifact: request.artifact, pid: helperProcess.child.pid }); + publishReady(readyPath, { application, helper, owner }); + await waitForParent(); + } finally { + await (host ?? native).dispose(); + claim.release(); + } +} + +main().catch((error) => { + process.stderr.write(`${error.stack ?? error.message}\n`); + process.exitCode = 1; +}); diff --git a/packages/agentic/storybook-desktop/references/contributing.md b/packages/agentic/storybook-desktop/references/contributing.md index a702579f16..63c5d3408a 100644 --- a/packages/agentic/storybook-desktop/references/contributing.md +++ b/packages/agentic/storybook-desktop/references/contributing.md @@ -102,13 +102,15 @@ 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. On macOS, `createApplicationIdentityResolver` uses the +and process start time. On every native endpoint, `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. +full native executable/session/AUMID evidence at the driver boundary; the epoch +journal intentionally keeps its existing PID/start schema. Identity resolution +never falls back to a temporary controlling attachment, including on an older +helper that does not implement inspection. 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 diff --git a/packages/agentic/storybook-desktop/src/driver/applicationIdentity.test.ts b/packages/agentic/storybook-desktop/src/driver/applicationIdentity.test.ts index ae07d21ad6..942ac87463 100644 --- a/packages/agentic/storybook-desktop/src/driver/applicationIdentity.test.ts +++ b/packages/agentic/storybook-desktop/src/driver/applicationIdentity.test.ts @@ -2,7 +2,7 @@ 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 type { 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'; @@ -12,15 +12,30 @@ 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) => { +test.each([ + ['macos', 'fabric'], + ['macos', 'paper'], + ['windows', 'fabric'], + ['win32', 'paper'], +] as const)( + 'launch → claim → embedded-driver attach → %s %s epoch inspection retains exact application exclusion', + async (endpoint, renderer) => { + const observed = + endpoint === 'macos' + ? identity + : { + ...identity, + processStartedAt: '2026-11-12T00:00:00.1234567Z', + executablePath: 'C:\\apps\\ReactApp.exe', + interactiveSession: 'windows-session:2', + }; + const incarnation = { processId: observed.processId, processStartedAt: observed.processStartedAt }; const directory = fs.mkdtempSync(path.join(process.cwd(), '.application-identity-test-')); const registration = { - endpoint: 'macos', + endpoint, id: 'storybook', launchModes: ['attach'], - platformName: 'macos', + platformName: endpoint === 'macos' ? 'macos' : 'windows', renderer, } satisfies Omit; const claim = createDesktopWorkloadClaim({ @@ -29,11 +44,11 @@ test.each(['fabric', 'paper'] as const)( targetId: registration.id, targetInstanceId: registration.id, }); - const native = new FakeDesktopHost({ endpoint: 'macos' }); + const native = new FakeDesktopHost({ endpoint }); let held = false; jest.spyOn(native, 'launch').mockImplementation(async () => { held = true; - return { ...identity, id: 'owned-application', ownership: 'launched', containment: 'uncontained' }; + return { ...observed, id: 'owned-application', ownership: 'launched', containment: 'uncontained' }; }); jest.spyOn(native, 'closeApplication').mockImplementation(async () => { held = false; @@ -41,14 +56,14 @@ test.each(['fabric', 'paper'] as const)( const inspectApplication = jest.fn(async (_target: DesktopTarget, signal?: AbortSignal) => { signal?.throwIfAborted(); expect(held).toBe(true); - return identity; + return observed; }); const createIdentityHost = jest.fn(() => { - const foreign = new FakeDesktopHost({ endpoint: 'macos' }); + const foreign = new FakeDesktopHost({ endpoint }); 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 { ...observed, id: 'foreign-application', ownership: 'attached' }; }); return foreign; }); @@ -61,13 +76,13 @@ test.each(['fabric', 'paper'] as const)( let server: Awaited> | undefined; try { expect(claim.record.resources).toEqual([ - expect.objectContaining({ ownership: 'owned', application: expect.objectContaining(identity) }), + expect.objectContaining({ ownership: 'owned', application: expect.objectContaining(observed) }), ]); 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' }, + alwaysMatch: { platformName: registration.platformName, 'furn:target': registration.id, 'furn:launchMode': 'attach' }, }); const before = await session.getWindowHandles(); const records = new Map(); @@ -89,7 +104,7 @@ test.each(['fabric', 'paper'] as const)( 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' }); + expect(JSON.parse(records.get('bridge-epoch.json')!)).toMatchObject({ application: incarnation, epoch: 'authenticated-epoch' }); await expect(session.getWindowHandles()).resolves.toEqual(before); const foreign = createIdentityHost(); await expect(foreign.attach({ ...registration, host: foreign })).rejects.toMatchObject({ @@ -112,9 +127,15 @@ test.each(['fabric', 'paper'] as const)( }, ); -test.each(['windows', 'win32'] as const)('keeps %s identity attachments isolated and disposes them on failure', async (endpoint) => { +test.each(['windows', 'win32'] as const)('uses read-only %s inspection without a temporary controlling attachment', async (endpoint) => { const host = new FakeDesktopHost({ endpoint }); - const inspectApplication = jest.fn(); + const nativeIdentity = { + ...identity, + processStartedAt: '2026-11-12T00:00:00.1234567Z', + executablePath: 'C:\\apps\\ReactTest.exe', + interactiveSession: 'windows-session:2', + }; + const inspectApplication = jest.fn().mockResolvedValueOnce(nativeIdentity).mockRejectedValueOnce(new Error('Identity unavailable')); const attach = jest.spyOn(host, 'attach'); const dispose = jest.spyOn(host, 'dispose'); const resolve = createApplicationIdentityResolver({ @@ -122,12 +143,12 @@ test.each(['windows', 'win32'] as const)('keeps %s identity attachments isolated 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); + attach.mockRejectedValue(new Error('A temporary controlling attachment is not permitted')); + await expect(resolve()).resolves.toEqual({ processId: nativeIdentity.processId, processStartedAt: nativeIdentity.processStartedAt }); await expect(resolve()).rejects.toThrow('Identity unavailable'); - expect(inspectApplication).not.toHaveBeenCalled(); - expect(dispose).toHaveBeenCalledTimes(2); + expect(inspectApplication).toHaveBeenCalledTimes(2); + expect(attach).not.toHaveBeenCalled(); + expect(dispose).not.toHaveBeenCalled(); }); test('a failed macOS observation neither falls back to controlling attachment nor disposes the session host', async () => { diff --git a/packages/agentic/storybook-desktop/src/driver/applicationIdentity.ts b/packages/agentic/storybook-desktop/src/driver/applicationIdentity.ts index 7c271560a4..5005fb8bc2 100644 --- a/packages/agentic/storybook-desktop/src/driver/applicationIdentity.ts +++ b/packages/agentic/storybook-desktop/src/driver/applicationIdentity.ts @@ -5,21 +5,13 @@ import type { BridgeEpochRecovery } from './BridgeEpochJournal.js'; export function createApplicationIdentityResolver(options: { host: Pick; target: DesktopTarget; - createIdentityHost(): DesktopHost; + /** @deprecated Inspection never acquires a second controlling attachment. */ + 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(); - } + const identity = await options.host.inspectApplication(options.target, signal); + // The epoch journal intentionally records only its existing PID/start schema, not the Windows ownership evidence. + return { processId: identity.processId, processStartedAt: identity.processStartedAt }; }; }