From d93711145bf76bfcc9a67b69faa466ab4eb6e2e2 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 12 Nov 2026 02:07:59 +0000 Subject: [PATCH 01/23] Add scoped attach-only runner grants and settled controller handoff Keep the existing service and workload claim while MCP explicitly relinquishes or an authorized operator preempts. Fence admission until prior work and leases drain, reserve the target across WDIO workers, and revoke/drain crashed-worker grants from their parent. Part of #4448. Storybook integration and retained launched applications remain separate phases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/scoped-runner-handoff.md | 7 + packages/agentic/desktop-driver-mcp/AGENTS.md | 4 + packages/agentic/desktop-driver-mcp/README.md | 5 + .../references/current-status.md | 33 +-- .../desktop-driver-mcp/references/tools.md | 14 +- .../desktop-driver-mcp/src/controller.ts | 57 ++++- .../desktop-driver-mcp/src/server.test.ts | 123 ++++++++++ .../agentic/desktop-driver-mcp/src/server.ts | 9 +- packages/agentic/desktop-driver/AGENTS.md | 4 + .../references/current-status.md | 1 + .../desktop-driver/references/security.md | 25 +- .../src/authority/delegation.test.ts | 17 ++ .../desktop-driver/src/authority/grants.ts | 40 +++- .../src/client/DesktopDriverClient.ts | 11 +- .../src/server/SessionManager.ts | 168 +++++++++++--- .../src/server/createDesktopDriverServer.ts | 69 +++++- .../src/server/runnerHandoff.test.ts | 190 +++++++++++++++ packages/agentic/desktop-test/AGENTS.md | 4 + packages/agentic/desktop-test/README.md | 3 + .../desktop-test/references/commands.md | 13 +- .../desktop-test/references/current-status.md | 6 +- .../desktop-test/references/lifecycle.md | 11 + .../desktop-test/references/wdio-suites.md | 49 ++++ .../desktop-test/src/agentConnection.ts | 33 ++- packages/agentic/desktop-test/src/cli.ts | 51 +++- .../agentic/desktop-test/src/driverService.ts | 45 +++- .../desktop-test/src/wdio/configuration.ts | 23 +- packages/agentic/desktop-test/src/wdio/run.ts | 70 ++++-- .../desktop-test/src/wdio/runner.contract.cjs | 180 +++++++++++++++ .../desktop-test/src/wdio/runner.test.ts | 32 +++ .../desktop-test/src/wdio/runnerConnection.ts | 218 ++++++++++++++++++ 31 files changed, 1394 insertions(+), 121 deletions(-) create mode 100644 .changeset/scoped-runner-handoff.md create mode 100644 packages/agentic/desktop-driver/src/server/runnerHandoff.test.ts create mode 100644 packages/agentic/desktop-test/src/wdio/runner.contract.cjs create mode 100644 packages/agentic/desktop-test/src/wdio/runner.test.ts create mode 100644 packages/agentic/desktop-test/src/wdio/runnerConnection.ts diff --git a/.changeset/scoped-runner-handoff.md b/.changeset/scoped-runner-handoff.md new file mode 100644 index 0000000000..c950f7a0e9 --- /dev/null +++ b/.changeset/scoped-runner-handoff.md @@ -0,0 +1,7 @@ +--- +'@fluentui-react-native/desktop-driver': patch +'@fluentui-react-native/desktop-test': patch +'@fluentui-react-native/desktop-driver-mcp': patch +--- + +Add attach-only scoped runner grants, drain-confirmed MCP relinquish and standalone WDIO handoff, with parent-owned worker-crash cleanup and private credential transport. Storybook handoff and retained launched applications remain unsupported. diff --git a/packages/agentic/desktop-driver-mcp/AGENTS.md b/packages/agentic/desktop-driver-mcp/AGENTS.md index 4c69ec530e..462c4c604e 100644 --- a/packages/agentic/desktop-driver-mcp/AGENTS.md +++ b/packages/agentic/desktop-driver-mcp/AGENTS.md @@ -31,6 +31,10 @@ only maps them onto MCP. same-incarnation/descriptor-principal rediscovery; never share or resume another connection's session. Disposal awaits child revocation, keeping terminal-only cleanup proof for lost responses and failed drains; unauthorized is not cleanup. +- Explicit relinquish fences admission and aborts current calls immediately, + before queued cleanup. Return success only after confirmed child/session + drain; retain the fence/proof after failure. Reopening uses a fresh child and + invalidates old session resources, never cross-connection resume. - Tie every artifact link to its session. Artifact ids restart in each session. - Progress ticks come from the adapter and never reach the service, so a waiting model does not keep an idle session alive. Cancellation aborts the HTTP call, diff --git a/packages/agentic/desktop-driver-mcp/README.md b/packages/agentic/desktop-driver-mcp/README.md index 6fdf584d5a..dd57f8d803 100644 --- a/packages/agentic/desktop-driver-mcp/README.md +++ b/packages/agentic/desktop-driver-mcp/README.md @@ -23,6 +23,11 @@ and drains the child, including ambiguous acquisitions; old services without delegation fail closed. See [Connection and setup](references/connection.md). +`desktop_session` with `action: "relinquish"` cancels and drain-confirms the +controller before an operator's generic attach-only WDIO runner takes over. +After runner cleanup, open a fresh session; no old resources are resumed. +See [session actions](references/tools.md#desktop_session). + ## Use it Register the server with an MCP client that launches it over stdio: diff --git a/packages/agentic/desktop-driver-mcp/references/current-status.md b/packages/agentic/desktop-driver-mcp/references/current-status.md index be788a2ea1..ca57ce4fab 100644 --- a/packages/agentic/desktop-driver-mcp/references/current-status.md +++ b/packages/agentic/desktop-driver-mcp/references/current-status.md @@ -19,22 +19,23 @@ and the generalized management facet is ## Capability matrix -| Capability | State | Boundary/remaining work | -| --------------------------------------------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Six tools over shared driver schemas | Done | Session, observe, act, check, capture, story. No separate execution path. | -| Private descriptor and native identity verification | Done | Verifies before sending a grant. Uses the driver's `/native`, `/authority`, and `/client` subpaths, not its WebdriverIO-heavy root; boundary tests guard this dependency. No model URL/token/config/executable input. | -| Single-flight connection/session lifetime | Done | Serial acquisition, close, operations and resource reads; late acquisition after dispose is cleaned up. | -| Same-port protected restart/revocation | Done in deterministic contracts | Invalid authority is forgotten; next call re-verifies discovery. No mutation replay. Live endpoint reruns remain required. | -| Same-authority transport retry | Done, bounded semantics | Retain known session cleanup responsibility and rebind only after verification of the same incarnation/principal. This is not cross-MCP-connection resume. | -| Lost create response | Fenced; child drain implemented | `session-uncertain`; no repeated acquisition. Connection disposal revokes and drains the child, including unknown session ids. Live descriptor-path reruns not run for this change. | -| Failed delete/disconnect | Done with limits | Keep known-session responsibility; bounded dispose wait leaves late cleanup attached. Unreachable-service cleanup cannot be guaranteed; service drain/expiry remains necessary. | -| Launch-mode discovery | Done | Status lists effective launch/attach modes; no predictable attach-first failure against launch-only targets. | -| Progress, cancellation, paged image resources | Done | Progress does not renew idle sessions; artifacts are session-bound and hash-checked. | -| Storybook control | Experimental | Driver selection/marker path exists; channel ingress/reconnect and full exclusive context authority remain incomplete. | -| Per-connection attenuated grants | Done in deterministic contracts | Each controller delegates a distinct driver-only child after verification; target/scope subsets and terminal-only cleanup retry. Native descriptor-path qualification not run for this change. | -| Cross-connection session resume | Intentionally unsupported | No lease/grace or ownership-transfer protocol. Same-controller rediscovery retains its existing child; no automatic replay or cross-connection resume. | -| Workload/test orchestration tools | Work in progress | Belong to a future harness control/client surface; adapter never becomes the resource owner. | -| General MCP-client qualification | Work in progress | SDK stdio evidence exists; client-specific resource/image/cancellation behavior needs independent runs. | +| Capability | State | Boundary/remaining work | +| --------------------------------------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Six tools over shared driver schemas | Done | Session, observe, act, check, capture, story. No separate execution path. | +| Private descriptor and native identity verification | Done | Verifies before sending a grant. Uses the driver's `/native`, `/authority`, and `/client` subpaths, not its WebdriverIO-heavy root; boundary tests guard this dependency. No model URL/token/config/executable input. | +| Single-flight connection/session lifetime | Done | Serial acquisition, close, operations and resource reads; late acquisition after dispose is cleaned up. | +| Same-port protected restart/revocation | Done in deterministic contracts | Invalid authority is forgotten; next call re-verifies discovery. No mutation replay. Live endpoint reruns remain required. | +| Same-authority transport retry | Done, bounded semantics | Retain known session cleanup responsibility and rebind only after verification of the same incarnation/principal. This is not cross-MCP-connection resume. | +| Lost create response | Fenced; child drain implemented | `session-uncertain`; no repeated acquisition. Connection disposal revokes and drains the child, including unknown session ids. Live descriptor-path reruns not run for this change. | +| Failed delete/disconnect | Done with limits | Keep known-session responsibility; bounded dispose wait leaves late cleanup attached. Unreachable-service cleanup cannot be guaranteed; service drain/expiry remains necessary. | +| Launch-mode discovery | Done | Status lists effective launch/attach modes; no predictable attach-first failure against launch-only targets. | +| Progress, cancellation, paged image resources | Done | Progress does not renew idle sessions; artifacts are session-bound and hash-checked. | +| Storybook control | Experimental | Driver selection/marker path exists; channel ingress/reconnect and full exclusive context authority remain incomplete. | +| Per-connection attenuated grants | Done in deterministic contracts | Each controller delegates a distinct driver-only child after verification; target/scope subsets and terminal-only cleanup retry. Native descriptor-path qualification not run for this change. | +| Explicit controller relinquish | Done in deterministic contracts | Immediate admission fence and cancellation; return only after child/session drain. Failures retain retryable cleanup responsibility. Fresh open expires old references, cursors, proofs and artifact links. Generic attach-only runner integration is in desktop-test; Storybook and native endpoint qualification remain separate. | +| Cross-connection session resume | Intentionally unsupported | No lease/grace or ownership-transfer protocol. Same-controller rediscovery retains its existing child; no automatic replay or cross-connection resume. | +| Workload/test orchestration tools | Work in progress | Belong to a future harness control/client surface; adapter never becomes the resource owner. | +| General MCP-client qualification | Work in progress | SDK stdio evidence exists; client-specific resource/image/cancellation behavior needs independent runs. | See [connection](connection.md) and [tools](tools.md) for exact behavior. Native and Storybook limitations are tracked in diff --git a/packages/agentic/desktop-driver-mcp/references/tools.md b/packages/agentic/desktop-driver-mcp/references/tools.md index 765176cb40..b4e1a1a9f7 100644 --- a/packages/agentic/desktop-driver-mcp/references/tools.md +++ b/packages/agentic/desktop-driver-mcp/references/tools.md @@ -26,7 +26,7 @@ UTF-8 bytes. See [measurement history](current-status.md). ## desktop_session -`action` is `status` (the default), `open`, or `close`. +`action` is `status` (the default), `open`, `close`, or `relinquish`. - `status` lists the targets the grant allows, with their endpoint, platform, renderer, effective `launchModes`, and whether a target is quarantined, plus @@ -39,6 +39,16 @@ UTF-8 bytes. See [measurement history](current-status.md). One session is open at a time; opening a second fails with `session-open`. - `close` confirms deletion before forgetting the known session. Closing the MCP connection stops admissions and attempts bounded cleanup, including late opens. +- `relinquish` stops admission immediately, cancels current operations, revokes + the connection child, and waits for native settlement and session deletion. + It returns `{ relinquished: true }` only after that drain. The operator's + standalone attach-only runner can then reserve the target without restarting + services. After the runner drains, this MCP connection may `open` with a + fresh child/session. Old references, cursors, proofs, and artifact links + expire; nothing is resumed or replayed. A failed drain reports + `drain-incomplete` and keeps new work fenced with `controller-draining`; + retry `relinquish`, never ordinary session work, until cleanup is confirmed. + This does not start a runner or grant workload/preemption rights to the model. ## desktop_observe @@ -114,7 +124,7 @@ A tool failure is an `isError` result with one JSON text block: | `mutation` | For an action, whether it was applied, rejected, or indeterminate, and whether it quarantined the target. | The adapter's own reasons are `no-session`, `session-open`, -`session-uncertain`, `connection-closed`, `service-unavailable`, `cancelled`, +`session-uncertain`, `connection-closed`, `controller-draining`, `drain-incomplete`, `service-unavailable`, `cancelled`, and `internal`. A malformed call is refused with the schema's validation message. diff --git a/packages/agentic/desktop-driver-mcp/src/controller.ts b/packages/agentic/desktop-driver-mcp/src/controller.ts index 969d462bd8..5e5e58cb5c 100644 --- a/packages/agentic/desktop-driver-mcp/src/controller.ts +++ b/packages/agentic/desktop-driver-mcp/src/controller.ts @@ -54,6 +54,8 @@ export class DesktopController { #pending: Promise = Promise.resolve(); #disposed = false; #disposing?: Promise; + #relinquishing?: Promise<{ relinquished: true }>; + #control = new AbortController(); readonly #disconnected = new AbortController(); constructor(connect: ConnectDesktopDriver) { @@ -83,13 +85,16 @@ export class DesktopController { let session: DesktopSessionClient; try { session = await this.guard(() => - connection.client.newSession({ - alwaysMatch: { - 'furn:launchMode': request.launch ? 'launch' : 'attach', - 'furn:target': request.target, - ...(request.clickMode === undefined ? {} : { 'furn:clickMode': request.clickMode }), + connection.client.newSession( + { + alwaysMatch: { + 'furn:launchMode': request.launch ? 'launch' : 'attach', + 'furn:target': request.target, + ...(request.clickMode === undefined ? {} : { 'furn:clickMode': request.clickMode }), + }, }, - }), + { signal: this.#control.signal }, + ), ); } catch (error) { if (isWebDriverError(error)) this.#uncertain = undefined; @@ -114,6 +119,38 @@ export class DesktopController { }); } + /** Stops admission at once; a fresh child is obtained only after the old controller's drain is confirmed. */ + relinquish(): Promise<{ relinquished: true }> { + if (this.#disposed) return Promise.reject(new AdapterError('connection-closed', 'The MCP connection is closed.')); + if (this.#relinquishing) return this.#relinquishing; + this.#control.abort(); + const binding = this.#binding; + // Do not wait behind the native command we must cancel. The service revokes first, then awaits its settlement. + const draining = binding?.child.client.revokeConnection(); + void draining?.catch(() => undefined); + const previous = this.#pending; + const result = (async () => { + await previous; + try { + if (draining) await draining; + if (this.#binding && this.#binding !== binding) await this.#binding.child.client.revokeConnection(); + this.#binding = undefined; + this.#connection = undefined; + this.#open = undefined; + this.#uncertain = undefined; + this.#control = new AbortController(); + this.#relinquishing = undefined; + return { relinquished: true as const }; + } catch { + this.#relinquishing = undefined; + throw new AdapterError('drain-incomplete', 'Controller cleanup is not confirmed. Retry relinquish; no new work is admitted.'); + } + })(); + this.#relinquishing = result; + this.#pending = result.catch(() => undefined); + return result; + } + private async closeSession(): Promise<{ closed?: string }> { const open = this.#open; if (!open) { @@ -142,7 +179,7 @@ export class DesktopController { if (!open) { throw new AdapterError('no-session', 'No session is open. Open one with desktop_session first.'); } - const signal = AbortSignal.any([this.#disconnected.signal, ...(options.signal ? [options.signal] : [])]); + const signal = AbortSignal.any([this.#disconnected.signal, this.#control.signal, ...(options.signal ? [options.signal] : [])]); const response = await this.guard(() => open.session.operation(name, input, { ...options, signal }), signal); return { response, session: open.session.id }; } @@ -296,8 +333,14 @@ export class DesktopController { } private enqueue(work: () => Promise): Promise { + if (this.#control.signal.aborted) { + return Promise.reject(new AdapterError('controller-draining', 'Complete relinquish before admitting new controller work.')); + } const result = this.#pending.then(() => { if (this.#disposed) throw new AdapterError('connection-closed', 'The MCP connection is closed.'); + if (this.#control.signal.aborted) { + throw new AdapterError('controller-draining', 'The controller is draining. Complete relinquish before opening a fresh session.'); + } return work(); }); this.#pending = result.catch(() => undefined); diff --git a/packages/agentic/desktop-driver-mcp/src/server.test.ts b/packages/agentic/desktop-driver-mcp/src/server.test.ts index f5eb7fd538..a2d0f031c9 100644 --- a/packages/agentic/desktop-driver-mcp/src/server.test.ts +++ b/packages/agentic/desktop-driver-mcp/src/server.test.ts @@ -112,6 +112,129 @@ async function open(client: Client, harness: DesktopDriverTestHarness): Promise< } describe('desktop-driver-mcp', () => { + test('relinquish fences admission immediately and waits for terminal deletion before issuing a fresh child', async () => { + const harness = await createDesktopDriverTestHarness({ features }); + const controller = new DesktopController(async () => testConnection(harness)); + const entered = deferred(); + const finish = deferred(); + const release = harness.host.releaseActions.bind(harness.host); + try { + const session = await controller.open({ launch: true, target: harness.target.id }); + const owner = harness.server.sessions.get(session.id).owner?.grantId; + jest.spyOn(harness.host, 'releaseActions').mockImplementationOnce(async (signal) => { + entered.resolve(); + await finish.promise; + return release(signal); + }); + let completed = false; + const handoff = controller.relinquish().then((result) => { + completed = true; + return result; + }); + await entered.promise; + await expect(controller.status()).rejects.toMatchObject({ reason: 'controller-draining' }); + await expect(controller.open({ launch: true, target: harness.target.id })).rejects.toMatchObject({ reason: 'controller-draining' }); + expect(completed).toBe(false); + finish.resolve(); + await expect(handoff).resolves.toEqual({ relinquished: true }); + expect(() => harness.server.sessions.get(session.id)).toThrow(); + expect(controller.session).toBeUndefined(); + await expect(controller.readArtifact(session.id, 'a1')).rejects.toMatchObject({ reason: 'artifact-expired' }); + const next = await controller.open({ launch: true, target: harness.target.id }); + expect(harness.server.sessions.get(next.id).owner?.grantId).not.toBe(owner); + } finally { + finish.resolve(); + await controller.dispose(); + await harness.close(); + } + }); + + test('relinquish cancels a running operation rather than waiting behind its queue', async () => { + const harness = await createDesktopDriverTestHarness({ features }); + const controller = new DesktopController(async () => testConnection(harness)); + const entered = deferred(); + try { + await controller.open({ launch: true, target: harness.target.id }); + const observe = harness.host.observe.bind(harness.host); + jest.spyOn(harness.host, 'observe').mockImplementationOnce(async (request, signal) => { + entered.resolve(); + await new Promise((resolve) => { + if (signal?.aborted) resolve(); + else signal?.addEventListener('abort', () => resolve(), { once: true }); + }); + signal?.throwIfAborted(); + return observe(request, signal); + }); + const pending = controller.run('observe', {}); + const cancelled = expect(pending).rejects.toMatchObject({ reason: 'cancelled' }); + await entered.promise; + await expect(controller.relinquish()).resolves.toEqual({ relinquished: true }); + await cancelled; + expect(controller.session).toBeUndefined(); + } finally { + await controller.dispose(); + await harness.close(); + } + }); + + test.each(['lost-response', 'drain-failure'] as const)('failed relinquish retains a retryable drain fence after %s', async (failure) => { + const harness = await createDesktopDriverTestHarness({ features }); + let injected = false; + const connection = testConnection(harness, async (input, init) => { + const response = await fetch(input, init); + if (!injected && String(input).endsWith('/furn/grants/current') && failure === 'lost-response') { + injected = true; + await response.body?.cancel(); + throw new Error('injected response loss'); + } + return response; + }); + const controller = new DesktopController(async () => connection); + try { + const session = await controller.open({ launch: true, target: harness.target.id }); + if (failure === 'drain-failure') + jest.spyOn(harness.server.sessions, 'deleteOwnedBy').mockRejectedValueOnce(new Error('injected drain failure')); + await expect(controller.relinquish()).rejects.toMatchObject({ reason: 'drain-incomplete' }); + await expect(controller.status()).rejects.toMatchObject({ reason: 'controller-draining' }); + await expect(controller.relinquish()).resolves.toEqual({ relinquished: true }); + expect(() => harness.server.sessions.get(session.id)).toThrow(); + await expect(controller.open({ launch: true, target: harness.target.id })).resolves.toBeDefined(); + } finally { + await controller.dispose(); + await harness.close(); + } + }); + + test('the MCP relinquish action expires session-bound artifacts, references, cursors, and proofs', async () => { + await withFixture({}, async ({ client, harness }) => { + const previous = await open(client, harness); + const observed = json(await call(client, 'desktop_observe', { observe: { budget: { maxNodes: 1 } } })).result as { + next: string; + sample: { id: string }; + }; + const found = json(await call(client, 'desktop_observe', { find: { automationId: 'button-primary' } })).result as { + matches: { ref: string }[]; + sample: { id: string }; + }; + const captured = await call(client, 'desktop_capture'); + const link = captured.content.find((block) => block.type === 'resource_link'); + expect(link).toBeDefined(); + const stale = found.matches[0].ref; + expect(json(await call(client, 'desktop_session', { action: 'relinquish' }))).toEqual({ relinquished: true }); + await open(client, harness); + const checked = await call(client, 'desktop_check', { ref: stale, expect: { enabled: true } }); + expect(checked.isError).toBe(true); + expect(json(checked)).toMatchObject({ reason: expect.stringMatching(/^ref-/) }); + const page = await call(client, 'desktop_observe', { read: { cursor: observed.next } }); + expect(page.isError).toBe(true); + expect(json(page)).toMatchObject({ reason: 'observation-cursor-expired' }); + const acted = await call(client, 'desktop_act', { steps: [{ act: { action: 'activate', ref: stale, sample: found.sample.id } }] }); + expect(json(acted)).toMatchObject({ result: { status: 'stopped', steps: [{ error: { reason: expect.any(String) } }] } }); + if (link?.type === 'resource_link') await expect(client.readResource({ uri: link.uri })).rejects.toThrow(); + expect(() => harness.server.sessions.get(previous)).toThrow(); + }); + }); + test('isolates controllers sharing a descriptor principal instead of sharing sessions', async () => { const harness = await createDesktopDriverTestHarness({ features }); const first = new DesktopController(async () => testConnection(harness)); diff --git a/packages/agentic/desktop-driver-mcp/src/server.ts b/packages/agentic/desktop-driver-mcp/src/server.ts index f45d4a9969..9af3e9bef4 100644 --- a/packages/agentic/desktop-driver-mcp/src/server.ts +++ b/packages/agentic/desktop-driver-mcp/src/server.ts @@ -59,7 +59,10 @@ const requestIdInput = z const sessionInput = z .strictObject({ - action: z.enum(['status', 'open', 'close']).default('status').describe('status lists targets and the open session.'), + action: z + .enum(['status', 'open', 'close', 'relinquish']) + .default('status') + .describe('relinquish cancels work and confirms controller drain.'), clickMode: z .enum(['accessibility', 'auto', 'physical']) .optional() @@ -126,7 +129,7 @@ export function createDesktopDriverMcpServer(options: DesktopDriverMcpServerOpti { annotations: { destructiveHint: false, idempotentHint: false, openWorldHint: false, readOnlyHint: false }, description: - 'Lists the desktop targets the service offers and the open session, opens a session on one target, or closes it. One session is open at a time.', + 'Lists targets, opens or closes a session, or relinquishes control after draining it for an external runner. One session is open at a time.', inputSchema: sessionInput, title: 'Desktop session', }, @@ -142,6 +145,8 @@ export function createDesktopDriverMcpServer(options: DesktopDriverMcpServerOpti ); case 'close': return jsonResult(await controller.close()); + case 'relinquish': + return jsonResult(await controller.relinquish()); default: return jsonResult(await controller.status()); } diff --git a/packages/agentic/desktop-driver/AGENTS.md b/packages/agentic/desktop-driver/AGENTS.md index 5f6d8d940d..9d3722004d 100644 --- a/packages/agentic/desktop-driver/AGENTS.md +++ b/packages/agentic/desktop-driver/AGENTS.md @@ -27,6 +27,10 @@ editing. authority synchronously and await acquisition/session drains; revoked-child proof authorizes only retrying that child's terminal cleanup, never driver access. Never evict cleanup proof to reuse the finite lifetime grant budget. +- Runner handoff fences admission before cancellation/drain and confirms + deletion before granting an attach-only reservation. Descendants inherit + the controller restriction. Do not release that reservation on notification + or add launched-app/Storybook retention implicitly. - Permit one active session per target. Serialize each session's commands and all physical input, including across independent helper processes. - Every host operation must honor its `AbortSignal`, stop side effects, settle diff --git a/packages/agentic/desktop-driver/references/current-status.md b/packages/agentic/desktop-driver/references/current-status.md index 507829248c..578e4dd5b8 100644 --- a/packages/agentic/desktop-driver/references/current-status.md +++ b/packages/agentic/desktop-driver/references/current-status.md @@ -26,6 +26,7 @@ and its sub-issues, which record dependencies and parallel waves. | Native sessions and command/input queues | Done | One reservation per registered target in one service. Cross-service exact-application claims remain work in progress. | | Verified private discovery and grants | Done | Native identity verified before credential transmission; all protected driver routes authenticate. Storybook agent manifests additionally disable alternate channel mutations in the integration adapter. | | Attenuated connection principals and permission scopes | Done in deterministic contracts | Driver/workload/recovery scopes are distinct; fixed driver-only HTTP delegation, target/scope subsets, synchronous subtree fencing and awaitable/retryable drain. Native descriptor-path requalification not run for this change. | +| Scoped attach-only runner handoff | Done in deterministic contracts | Workload connection reserves one generic attached target only after confirmed old-controller drain. Descendants inherit attach-only scope; parent reservations exclude MCP between workers. Busy is the default, failed cleanup stays fenced, preemption needs explicit recovery authority. Storybook, retained launches, and native endpoint qualification remain separate. | | Mutation effects, settlement, quarantine | Done | Native mutations settle before advancing. Uncertain story changes now quarantine instead of assuming a later render is safe. | | Durable settlement storage and trusted recovery API | Done | Generic/Storybook native paths share the harness factory's private storage and exact-incarnation probes. Direct library embedders must pass `recovery`; restart is not proof. | | Bounded observation, references, paging, deltas | Done | Provider capability/budget checks, explicit completeness and truncation. Reference generation is session-local; reopen after application replacement. | diff --git a/packages/agentic/desktop-driver/references/security.md b/packages/agentic/desktop-driver/references/security.md index 74080fffdc..1ce01ce094 100644 --- a/packages/agentic/desktop-driver/references/security.md +++ b/packages/agentic/desktop-driver/references/security.md @@ -70,7 +70,9 @@ in its command queue authenticates again immediately before dispatch. - A grant also names explicit permission scopes: `driver-control`, `declared-workload`, and `operator-recovery`. Omitted scopes mean only `driver-control`. Every protected driver route requires that scope; the other - scopes alone confer no driver access. Workload and recovery HTTP routes are + scopes alone confer no driver access. Scoped attach-only runner grants use + workload authority; preempting an existing controller additionally requires + recovery authority. General workload management and recovery HTTP routes are not implemented. Trusted in-process recovery remains an operator API. - `authority.delegate(parent.id, request)` creates a distinct child principal, rejecting target or scope expansion (including wildcard expansion). An @@ -114,13 +116,32 @@ in its command queue authenticates again immediately before dispatch. - Ordinary session HTTP `DELETE` is queued behind current work before drain begins. Neither revocation notification nor a ten-minute idle setting proves settlement; handoff must await session deletion or the explicit grant drain. +- `POST /furn/grants/runner-connection` accepts no fields and delegates an + isolated connection retaining the caller's scopes; it requires + `declared-workload` as well as `driver-control`. + `POST /furn/grants/runner` accepts only `{ target, handoff?: "busy" | "preempt" }` + from such an isolated workload connection. The target must be generic and + advertise attach-only launch modes. The default is bounded `target-busy`; + `preempt` also requires `operator-recovery` and cannot revoke the acquiring + connection or one of its ancestors. +- Runner acquisition fences admission before draining an old controller. Only + after cancellation, native settlement, opening-lease cleanup, and deletion + are confirmed does it return a driver-only, target-scoped attach-only child. + Ordinary descendants inherit that restriction and share the reservation. + The reservation excludes MCP even between workers and is released only after + terminal subtree drain. Failed drains retain the fence and retryable proof. + This is not a session transfer: old references, cursors, proofs, request-id + outcomes and session-qualified artifact links are invalid in the fresh + session. Storybook and supervisor-retained launched-app handoff are not + implemented here. - A grant's session that receives no request for `idleSessionTimeoutMs` (ten minutes by default; 0 disables) closes as `DELETE` would: input is released, a launched application is closed, and an attached application is left running with any quarantine kept. The clock runs only while none of the session's requests is queued or running, and only the owning grant's requests renew it; another grant's probe does not. Expiry - releases the target for other grants, so a client that keeps a session + releases a normal target for other grants (a runner reservation lasts until + its grant drains), so a client that keeps a session must keep using it. Sessions on a service without an authority never expire. - Grants and the service `incarnation` that `/status` reports as `service.incarnation` end with the service process. A restarted service diff --git a/packages/agentic/desktop-driver/src/authority/delegation.test.ts b/packages/agentic/desktop-driver/src/authority/delegation.test.ts index e1e2a76453..c8cec8cb83 100644 --- a/packages/agentic/desktop-driver/src/authority/delegation.test.ts +++ b/packages/agentic/desktop-driver/src/authority/delegation.test.ts @@ -1,6 +1,23 @@ import { DesktopAuthority } from './grants.js'; describe('attenuated grants', () => { + test('runner descendants inherit their target reservation and attach-only restriction without workload or recovery rights', () => { + const authority = new DesktopAuthority(); + const parent = authority.issue({ + label: 'operator', + targets: ['a', 'b'], + scopes: ['driver-control', 'declared-workload', 'operator-recovery'], + }); + const runner = authority.delegateRunner(parent.id, 'a'); + const worker = authority.delegate(runner.id, { label: 'worker', targets: ['a'] }); + expect(runner.targets).toEqual(['a']); + expect(worker.scopes).toEqual(['driver-control']); + expect(worker.runner).toEqual({ controllerId: runner.id, attachOnly: true }); + expect(Object.isFrozen(worker.runner)).toBe(true); + expect(() => authority.delegateRunner(runner.id, 'a')).toThrow(); + expect(() => authority.delegateRunner(parent.id, 'outside')).toThrow(); + expect(() => authority.delegate(worker.id, { label: 'expand', targets: ['b'] })).toThrow(); + }); test('isolates siblings even when their labels and target scopes match', () => { const authority = new DesktopAuthority(); const parent = authority.issue({ label: 'agent', targets: ['a', 'b'] }); diff --git a/packages/agentic/desktop-driver/src/authority/grants.ts b/packages/agentic/desktop-driver/src/authority/grants.ts index e32c2320a4..a25e1ef38f 100644 --- a/packages/agentic/desktop-driver/src/authority/grants.ts +++ b/packages/agentic/desktop-driver/src/authority/grants.ts @@ -27,6 +27,8 @@ export type DesktopGrant = { readonly targets: GrantTargets; readonly scopes: readonly GrantScope[]; readonly parentId?: string; + /** Runner descendants share one reserved controller and can only attach, never launch an application. */ + readonly runner?: Readonly<{ controllerId: string; attachOnly: true }>; }; /** A new grant with its bearer token. The token is returned once and cannot be recovered from the authority. */ @@ -76,7 +78,33 @@ export class DesktopAuthority { return this.create({ ...request, scopes, targets }, parent); } - private create(request: GrantRequest, parent: StoredGrant | undefined): IssuedDesktopGrant { + /** A trusted workload caller obtains only one target's attach-only driver authority, not workload or recovery rights. */ + delegateRunner(parentId: string, targetId: string): IssuedDesktopGrant { + const parent = this.grants.get(parentId); + if ( + !parent || + parent.depth >= maxDepth || + !parent.grant.scopes.includes('driver-control') || + !parent.grant.scopes.includes('declared-workload') || + !grantAllowsTarget(parent.grant, targetId) + ) { + throw new TypeError('Runner delegation is unavailable.'); + } + return this.create({ label: 'wdio', scopes: ['driver-control'], targets: [targetId] }, parent, true); + } + + /** Includes the grant itself; preemption must not revoke the acquiring controller's own authority tree. */ + isDescendantOf(grantId: string, ancestorId: string): boolean { + let current = this.grants.get(grantId) ?? this.revoked.get(grantId); + while (current) { + if (current.grant.id === ancestorId) return true; + const parentId = current.grant.parentId ?? ''; + current = this.grants.get(parentId) ?? this.revoked.get(parentId); + } + return false; + } + + private create(request: GrantRequest, parent: StoredGrant | undefined, runner = false): IssuedDesktopGrant { if (this.issuedCount >= maxGrants) throw new RangeError('The service grant limit has been reached.'); const label = request.label; if (typeof label !== 'string' || !labelPattern.test(label)) { @@ -86,7 +114,15 @@ export class DesktopAuthority { const scopes = normalizeScopes(request.scopes); const id = randomBytes(16).toString('hex'); const secret = randomBytes(32).toString('base64url'); - const grant: DesktopGrant = Object.freeze({ id, label, targets, scopes, ...(parent ? { parentId: parent.grant.id } : {}) }); + const restriction = parent?.grant.runner ?? (runner ? Object.freeze({ controllerId: id, attachOnly: true as const }) : undefined); + const grant: DesktopGrant = Object.freeze({ + id, + label, + targets, + scopes, + ...(parent ? { parentId: parent.grant.id } : {}), + ...(restriction ? { runner: restriction } : {}), + }); this.grants.set(id, { digest: digestOf(secret), grant, depth: parent ? parent.depth + 1 : 0 }); this.issuedCount += 1; return Object.freeze({ ...grant, token: `${tokenScheme}.${id}.${secret}` }); diff --git a/packages/agentic/desktop-driver/src/client/DesktopDriverClient.ts b/packages/agentic/desktop-driver/src/client/DesktopDriverClient.ts index f321c6be9a..fb496aa2fb 100644 --- a/packages/agentic/desktop-driver/src/client/DesktopDriverClient.ts +++ b/packages/agentic/desktop-driver/src/client/DesktopDriverClient.ts @@ -95,10 +95,13 @@ export class DesktopDriverClient { return this.request('DELETE', '/furn/grants/current'); } - async newSession(capabilities: NewSessionCapabilities): Promise { - const value = await this.request<{ capabilities: Record; sessionId: string }>('POST', '/session', { - capabilities, - }); + async newSession(capabilities: NewSessionCapabilities, options: DesktopClientCallOptions = {}): Promise { + const value = await this.request<{ capabilities: Record; sessionId: string }>( + 'POST', + '/session', + { capabilities }, + options.signal, + ); return new DesktopSessionClient(this, value.sessionId, value.capabilities); } diff --git a/packages/agentic/desktop-driver/src/server/SessionManager.ts b/packages/agentic/desktop-driver/src/server/SessionManager.ts index 925bff93c7..0936a5af00 100644 --- a/packages/agentic/desktop-driver/src/server/SessionManager.ts +++ b/packages/agentic/desktop-driver/src/server/SessionManager.ts @@ -43,6 +43,7 @@ export type MutationOptions = { export type SessionOwner = { grantId: string; label: string; + controllerId?: string; }; /** Who has a target reserved, as far as a contending caller may be told. `label` is set only for a grant's session. */ @@ -128,8 +129,15 @@ export class SessionManager { private readonly sessions = new Map(); private readonly sessionsByTarget = new Map(); private readonly reservedTargets = new Map(); + private readonly runnerReservations = new Map(); + private readonly handoffs = new Map }>(); private readonly commandQueues = new Map>(); - private readonly inFlightCreates = new Map, { controller: AbortController; grantId?: string }>(); + private readonly inFlightCreates = new Map< + Promise, + { controller: AbortController; grantId?: string; controllerId?: string; targetId: string } + >(); + private readonly failedCreates = new Map(); + private readonly failedCreateDrains = new Map>(); private readonly revokedOwners = new Set(); private readonly quarantines = new Map(); private inputQueue: Promise = Promise.resolve(); @@ -259,11 +267,70 @@ export class SessionManager { return ( this.sessionsByTarget.has(targetId) || this.reservedTargets.has(targetId) || + this.runnerReservations.has(targetId) || + this.handoffs.has(targetId) || this.mutatingTargets.has(targetId) || this.recoveringTargets.has(targetId) ); } + /** + * Reserves admission before draining the previous owner. Notification of revocation is not proof: the supplied drain + * must settle and the target must have no session, opening lease, or mutation before this controller can create. + */ + reserveRunner(targetId: string, owner: SessionOwner, preempt?: (grantId: string) => Promise, signal?: AbortSignal): Promise { + if (this.closing || this.revokedOwners.has(owner.grantId)) { + return Promise.reject(new WebDriverError('session not created', 'Runner authority is unavailable.')); + } + if (this.handoffs.has(targetId)) return Promise.reject(this.createBusyError(targetId)); + const sessionId = this.sessionsByTarget.get(targetId); + const session = sessionId ? this.sessions.get(sessionId) : undefined; + const holder = this.runnerReservations.get(targetId) ?? this.reservedTargets.get(targetId); + const previous = holder?.grantId ?? session?.owner?.grantId; + if (this.isHeld(targetId) && (!preempt || !previous)) return Promise.reject(this.createBusyError(targetId)); + // Launch retention belongs to an explicit application owner, not to an attach-only handoff. + if (session?.lease.ownership === 'launched') { + return Promise.reject(new WebDriverError('unsupported operation', 'Runner handoff supports attached applications only.')); + } + const controller = new AbortController(); + const abort = () => controller.abort(signal?.reason); + signal?.addEventListener('abort', abort, { once: true }); + if (signal?.aborted) abort(); + let complete!: () => void; + const settled = new Promise((resolve) => (complete = resolve)); + this.handoffs.set(targetId, { owner, controller, settled }); + const result = (async () => { + try { + if (previous) await preempt!(previous); + controller.signal.throwIfAborted(); + if (this.closing || this.revokedOwners.has(owner.grantId)) { + throw new WebDriverError('session not created', 'Runner authority was revoked while draining.'); + } + if ( + this.sessionsByTarget.has(targetId) || + this.reservedTargets.has(targetId) || + this.runnerReservations.has(targetId) || + this.mutatingTargets.has(targetId) || + this.recoveringTargets.has(targetId) || + [...this.inFlightCreates.values()].some((create) => create.targetId === targetId) + ) { + throw this.createBusyError(targetId); + } + this.runnerReservations.set(targetId, { + grantId: owner.grantId, + label: owner.label, + lifecycle: 'active', + since: new Date().toISOString(), + }); + } finally { + signal?.removeEventListener('abort', abort); + this.handoffs.delete(targetId); + complete(); + } + })(); + return result; + } + create( target: DesktopTarget, hostInfo: DesktopHostInfo, @@ -277,7 +344,7 @@ export class SessionManager { } // Checked in the same tick that the create registers as in flight, so a create is either rejected here or awaited // by deleteOwnedBy. - if (owner && this.revokedOwners.has(owner.grantId)) { + if (owner && (this.revokedOwners.has(owner.grantId) || (owner.controllerId && this.revokedOwners.has(owner.controllerId)))) { return Promise.reject(new WebDriverError('session not created', 'The grant for this request was revoked.')); } const controller = new AbortController(); @@ -286,7 +353,7 @@ export class SessionManager { () => undefined, () => undefined, ); - this.inFlightCreates.set(settled, { controller, ...(owner ? { grantId: owner.grantId } : {}) }); + this.inFlightCreates.set(settled, { controller, targetId: target.id, ...owner }); void settled.finally(() => this.inFlightCreates.delete(settled)); return result; } @@ -297,6 +364,7 @@ export class SessionManager { clearTimeout(timer); } this.idleTimers.clear(); + for (const { controller } of this.handoffs.values()) controller.abort(); for (const { controller } of this.inFlightCreates.values()) { controller.abort(); } @@ -308,7 +376,7 @@ export class SessionManager { async waitForCreates(grantId?: string): Promise { while (true) { const pending = Array.from(this.inFlightCreates.entries()) - .filter(([, create]) => grantId === undefined || create.grantId === grantId) + .filter(([, create]) => grantId === undefined || create.grantId === grantId || create.controllerId === grantId) .map(([promise]) => promise); if (pending.length === 0) { return; @@ -338,9 +406,13 @@ export class SessionManager { if (this.closing) { throw new WebDriverError('session not created', 'Desktop Driver is shutting down.'); } - if (owner && this.revokedOwners.has(owner.grantId)) { + if (owner && (this.revokedOwners.has(owner.grantId) || (owner.controllerId && this.revokedOwners.has(owner.controllerId)))) { throw new WebDriverError('session not created', 'The grant for this request was revoked.'); } + const runner = this.runnerReservations.get(target.id); + if (this.handoffs.has(target.id) || (runner && runner.grantId !== (owner?.controllerId ?? owner?.grantId))) { + throw this.createBusyError(target.id); + } const quarantine = this.quarantines.get(target.id); if (quarantine) { throw createQuarantineError('session not created', quarantine, this.redactSessionIds); @@ -380,7 +452,7 @@ export class SessionManager { if (this.closing) { throw new WebDriverError('session not created', 'Desktop Driver shut down while the target was starting.'); } - if (owner && this.revokedOwners.has(owner.grantId)) { + if (owner && (this.revokedOwners.has(owner.grantId) || (owner.controllerId && this.revokedOwners.has(owner.controllerId)))) { throw new WebDriverError('session not created', 'The grant for this request was revoked.'); } const session: DesktopSession = { @@ -422,31 +494,19 @@ export class SessionManager { this.armIdleTimer(session.id); return session; } catch (error) { - await this.runInputCommand(() => - withCommandTimeout((signal) => target.host.releaseActions(signal), 10_000, `Releasing input for target "${target.id}"`), - ).catch(() => undefined); - if (lease) { - const failedLease = lease; - await withCommandTimeout( - (signal) => target.host.closeApplication(failedLease, signal), - 10_000, - `Closing failed target "${target.id}"`, - ).catch(() => undefined); - } else if (leasePromise) { + // The timeout has awaited native settlement, but an aborted attach may still have returned a lease. + lease ??= await leasePromise?.catch(() => undefined); + this.failedCreates.set(target.id, { target, owner, lease }); + try { + await this.cleanupFailedCreate(target.id); + } catch (cleanup) { releaseReservation = false; - await leasePromise - .then(async (lateLease) => { - await this.runInputCommand(() => - withCommandTimeout((signal) => target.host.releaseActions(signal), 10_000, `Releasing late input for target "${target.id}"`), - ).catch(() => undefined); - await withCommandTimeout( - (signal) => target.host.closeApplication(lateLease, signal), - 10_000, - `Closing late target "${target.id}"`, - ).catch(() => undefined); - }) - .catch(() => undefined) - .finally(() => this.reservedTargets.delete(target.id)); + this.reservedTargets.set(target.id, { + ...(owner ? { grantId: owner.grantId, label: owner.label } : {}), + lifecycle: 'draining', + since: new Date().toISOString(), + }); + throw new AggregateError([error, cleanup], 'Session acquisition and drain failed.'); } throw error; } finally { @@ -456,6 +516,31 @@ export class SessionManager { } } + private cleanupFailedCreate(targetId: string): Promise { + const pending = this.failedCreateDrains.get(targetId); + if (pending) return pending; + const result = this.drainFailedCreate(targetId).finally(() => this.failedCreateDrains.delete(targetId)); + this.failedCreateDrains.set(targetId, result); + return result; + } + + private async drainFailedCreate(targetId: string): Promise { + const failed = this.failedCreates.get(targetId); + if (!failed) return; + await this.runInputCommand(() => + withCommandTimeout((signal) => failed.target.host.releaseActions(signal), 10_000, 'Releasing input after failed acquisition'), + ); + if (failed.lease) { + await withCommandTimeout( + (signal) => failed.target.host.closeApplication(failed.lease!, signal), + 10_000, + 'Closing a failed acquisition', + ); + } + this.failedCreates.delete(targetId); + this.reservedTargets.delete(targetId); + } + /** * Why a target cannot be opened right now. The holder is described only by the label its grant chose, so a caller that * is merely contending learns who to wait for without learning any grant or session identifier. @@ -464,6 +549,7 @@ export class SessionManager { const sessionId = this.sessionsByTarget.get(targetId); const session = sessionId ? this.sessions.get(sessionId) : undefined; const holder: TargetHolder | undefined = + this.runnerReservations.get(targetId) ?? this.reservedTargets.get(targetId) ?? (session ? { @@ -798,7 +884,10 @@ export class SessionManager { } async deleteAll(): Promise { + await Promise.all([...this.handoffs.values()].map(({ settled }) => settled)); await this.deleteWhere(() => true, 'One or more desktop sessions failed to close.'); + for (const targetId of this.failedCreates.keys()) await this.cleanupFailedCreate(targetId); + this.runnerReservations.clear(); } /** @@ -807,23 +896,35 @@ export class SessionManager { */ async deleteOwnedBy(grantId: string): Promise { this.revokedOwners.add(grantId); + const handoffs = [...this.handoffs.values()].filter(({ owner }) => owner.grantId === grantId || owner.controllerId === grantId); + for (const { controller } of handoffs) controller.abort(); for (const [targetId, reservation] of this.reservedTargets) { if (reservation.grantId === grantId) { this.reservedTargets.set(targetId, { ...reservation, lifecycle: 'draining' }); } } for (const create of this.inFlightCreates.values()) { - if (create.grantId === grantId) { + if (create.grantId === grantId || create.controllerId === grantId) { create.controller.abort(); } } for (const session of this.sessions.values()) { - if (session.owner?.grantId === grantId) { + if (session.owner?.grantId === grantId || session.owner?.controllerId === grantId) { this.beginDraining(session); } } + await Promise.all(handoffs.map(({ settled }) => settled)); await this.waitForCreates(grantId); - await this.deleteWhere((session) => session.owner?.grantId === grantId, 'One or more sessions of a revoked grant failed to close.'); + await this.deleteWhere( + (session) => session.owner?.grantId === grantId || session.owner?.controllerId === grantId, + 'One or more sessions of a revoked grant failed to close.', + ); + for (const [targetId, failed] of this.failedCreates) { + if (failed.owner?.grantId === grantId || failed.owner?.controllerId === grantId) await this.cleanupFailedCreate(targetId); + } + for (const [targetId, reservation] of this.runnerReservations) { + if (reservation.grantId === grantId) this.runnerReservations.delete(targetId); + } } private async deleteWhere(predicate: (session: DesktopSession) => boolean, summary: string): Promise { @@ -852,6 +953,7 @@ export class SessionManager { if (session.lifecycle === 'active') { session.lifecycle = 'draining'; session.commandAbortController.abort(); + session.closeRequested.abort(); } this.reservedTargets.set(session.target.id, { ...(session.owner ? { grantId: session.owner.grantId, label: session.owner.label } : {}), diff --git a/packages/agentic/desktop-driver/src/server/createDesktopDriverServer.ts b/packages/agentic/desktop-driver/src/server/createDesktopDriverServer.ts index 26911744e7..754e154acf 100644 --- a/packages/agentic/desktop-driver/src/server/createDesktopDriverServer.ts +++ b/packages/agentic/desktop-driver/src/server/createDesktopDriverServer.ts @@ -385,16 +385,68 @@ async function route(context: RouteContext, sessions: SessionManager, targets: T const { body, method, segments } = context; if (segments.length === 3 && segments[0] === 'furn' && segments[1] === 'grants' && context.authority && context.grant) { - if (method === 'POST' && segments[2] === 'connection') { + if (method === 'POST' && (segments[2] === 'connection' || segments[2] === 'runner-connection')) { if (Object.keys(requireObject(body, 'Connection delegation')).length !== 0) throw invalidArgument('Connection delegation accepts no input.'); + const runner = segments[2] === 'runner-connection'; + if (runner && !context.grant.scopes.includes('declared-workload')) { + throw new WebDriverError('unauthorized', 'Declared workload authority is required.'); + } const child = context.authority.delegate(context.grant.id, { - label: context.grant.label, - scopes: ['driver-control'], + label: runner ? 'wdio' : context.grant.label, + scopes: runner ? context.grant.scopes : ['driver-control'], targets: context.grant.targets, }); return { token: child.token }; } + if (method === 'POST' && segments[2] === 'runner') { + const request = requireObject(body, 'Runner grant'); + rejectUnknownKeys(request, { target: true, handoff: true }, 'runner grant input'); + if ( + typeof request.target !== 'string' || + (request.handoff !== undefined && request.handoff !== 'busy' && request.handoff !== 'preempt') + ) { + throw invalidArgument('Runner grants require a target and an optional busy or preempt handoff.'); + } + if (!context.grant.scopes.includes('declared-workload') || !context.grant.parentId) { + throw new WebDriverError('unauthorized', 'An isolated declared workload connection is required.'); + } + if (request.handoff === 'preempt' && !context.grant.scopes.includes('operator-recovery')) { + throw new WebDriverError('unauthorized', 'Preemption requires explicit operator recovery authority.'); + } + const target = visibleTargets(context, targets).find(({ id }) => id === request.target); + if (!target) throw new WebDriverError('session not created', 'No registered target matches the runner request.'); + if (target.storyOrchestrator || target.launchModes?.length !== 1 || target.launchModes[0] !== 'attach') { + throw new WebDriverError('unsupported operation', 'Runner handoff requires a generic attach-only target.'); + } + const child = context.authority.delegateRunner(context.grant.id, target.id); + try { + await sessions.reserveRunner( + target.id, + { grantId: child.id, controllerId: child.id, label: child.label }, + request.handoff === 'preempt' + ? (id) => { + if (context.authority!.isDescendantOf(child.id, id)) { + throw new WebDriverError( + 'session not created', + 'Relinquish the acquiring connection before requesting runner authority.', + { + reason: 'target-busy', + }, + ); + } + return context.authority!.revokeAndDrain(id); + } + : undefined, + context.signal, + ); + context.signal.throwIfAborted(); + return { token: child.token }; + } catch (error) { + await context.authority.revokeAndDrain(child.id); + throw error; + } + } if (method === 'DELETE' && segments[2] === 'current') { if (!context.grant.parentId) { const error = new WebDriverError('unauthorized', 'Only a connection child can revoke itself.'); @@ -441,8 +493,17 @@ async function route(context: RouteContext, sessions: SessionManager, targets: T } // A target outside the grant matches as if it were not registered, so a grant cannot discover what it may not use. const matched = await matchCapabilities(request.capabilities, visibleTargets(context, targets)); + if (context.grant?.runner && matched.launchMode !== 'attach') { + throw new WebDriverError('session not created', 'Runner grants permit attached sessions only.'); + } try { - const owner = context.grant ? { grantId: context.grant.id, label: context.grant.label } : undefined; + const owner = context.grant + ? { + grantId: context.grant.id, + label: context.grant.label, + ...(context.grant.runner ? { controllerId: context.grant.runner.controllerId } : {}), + } + : undefined; const session = await sessions.create( matched.target, matched.hostInfo, diff --git a/packages/agentic/desktop-driver/src/server/runnerHandoff.test.ts b/packages/agentic/desktop-driver/src/server/runnerHandoff.test.ts new file mode 100644 index 0000000000..addd024de9 --- /dev/null +++ b/packages/agentic/desktop-driver/src/server/runnerHandoff.test.ts @@ -0,0 +1,190 @@ +import { DesktopAuthority } from '../authority/grants.js'; +import { createDesktopDriverClient } from '../client/DesktopDriverClient.js'; +import type { DesktopDriverClient } from '../client/DesktopDriverClient.js'; +import { FakeDesktopHost } from '../hosts/fake/FakeDesktopHost.js'; +import { attachFailureEffect } from '../protocol/settlement.js'; +import { createDesktopDriverServer } from './createDesktopDriverServer.js'; + +function barrier() { + let release!: () => void; + const pending = new Promise((resolve) => (release = resolve)); + return { pending, release }; +} + +async function fixture( + body: (value: { + authority: DesktopAuthority; + host: FakeDesktopHost; + agent: DesktopDriverClient; + newAgent(): Promise; + runner: DesktopDriverClient; + operator: DesktopDriverClient; + url: string; + capabilities: { alwaysMatch: Record }; + }) => Promise, +): Promise { + const authority = new DesktopAuthority(); + const host = new FakeDesktopHost({ features: { boundedObservation: true } }); + const target = { endpoint: 'windows' as const, host, id: 'attached', platformName: 'windows' as const, launchModes: ['attach' as const] }; + const agent = authority.issue({ label: 'agent', targets: [target.id] }); + const runner = authority.issue({ label: 'wdio', targets: [target.id], scopes: ['driver-control', 'declared-workload'] }); + const operator = authority.issue({ + label: 'operator', + targets: [target.id], + scopes: ['driver-control', 'declared-workload', 'operator-recovery'], + }); + const server = await createDesktopDriverServer({ authority, targets: [target] }); + const client = (token: string) => createDesktopDriverClient({ grant: token, url: server.url }); + try { + await body({ + authority, + host, + agent: await client(agent.token).delegateConnection(), + newAgent: () => client(agent.token).delegateConnection(), + runner: await runnerConnection(client(runner.token), server.url), + operator: await runnerConnection(client(operator.token), server.url), + url: server.url, + capabilities: { alwaysMatch: { 'furn:target': target.id, 'furn:launchMode': 'attach' } }, + }); + } finally { + await server.close(); + } +} + +async function runnerConnection(client: DesktopDriverClient, url: string): Promise { + const { token } = await client.request<{ token: string }>('POST', '/furn/grants/runner-connection', {}); + return createDesktopDriverClient({ grant: token, url }); +} + +async function acquire(client: DesktopDriverClient, url: string, handoff?: 'preempt'): Promise { + const { token } = await client.request<{ token: string }>('POST', '/furn/grants/runner', { + target: 'attached', + ...(handoff ? { handoff } : {}), + }); + return createDesktopDriverClient({ grant: token, url }); +} + +describe('attach-only runner handoff', () => { + test('defaults to bounded busy and never gives the MCP child workload or preemption authority', async () => { + await fixture(async ({ agent, capabilities, operator, runner, url }) => { + const session = await agent.newSession(capabilities); + await expect(acquire(runner, url)).rejects.toMatchObject({ data: { reason: 'target-busy' } }); + await expect(acquire(runner, url, 'preempt')).rejects.toMatchObject({ code: 'unauthorized' }); + await expect(acquire(agent, url, 'preempt')).rejects.toMatchObject({ code: 'unauthorized' }); + await expect(runnerConnection(agent, url)).rejects.toMatchObject({ code: 'unauthorized' }); + await expect(acquire(operator, url, 'preempt')).resolves.toBeDefined(); + await expect(session.getWindowHandles()).rejects.toMatchObject({ code: 'unauthorized' }); + }); + }); + + test('refuses preemption of an ancestor rather than joining its own pending handoff drain', async () => { + await fixture(async ({ capabilities, operator, url }) => { + const session = await operator.newSession(capabilities); + await expect(acquire(operator, url, 'preempt')).rejects.toMatchObject({ data: { reason: 'target-busy' } }); + await expect(session.getWindowHandles()).resolves.not.toHaveLength(0); + }); + }); + + test('fences admission across settlement, deletion, reservation, and descendant worker drain', async () => { + await fixture(async ({ agent, capabilities, host, newAgent, operator, url }) => { + await agent.newSession(capabilities); + const entered = barrier(); + const finish = barrier(); + const release = host.releaseActions.bind(host); + jest.spyOn(host, 'releaseActions').mockImplementationOnce(async (signal) => { + entered.release(); + await finish.pending; + return release(signal); + }); + let completed = false; + const handoff = acquire(operator, url, 'preempt').then((value) => { + completed = true; + return value; + }); + await entered.pending; + const contender = await newAgent(); + await expect(contender.newSession(capabilities)).rejects.toMatchObject({ data: { reason: 'target-busy' } }); + expect(completed).toBe(false); + finish.release(); + const runner = await handoff; + await expect(contender.newSession(capabilities)).rejects.toMatchObject({ data: { reason: 'target-busy' } }); + const worker = await runner.delegateConnection(); + const session = await worker.newSession(capabilities); + await expect(contender.request('GET', `/session/${session.id}/window`)).rejects.toMatchObject({ code: 'invalid session id' }); + const workerEntered = barrier(); + const workerFinish = barrier(); + jest.spyOn(host, 'releaseActions').mockImplementationOnce(async (signal) => { + workerEntered.release(); + await workerFinish.pending; + return release(signal); + }); + const drain = operator.revokeConnection(); + await workerEntered.pending; + await expect(contender.newSession(capabilities)).rejects.toMatchObject({ data: { reason: 'target-busy' } }); + workerFinish.release(); + await drain; + const next = await contender.newSession(capabilities); + await next.delete(); + await expect(session.getWindowHandles()).rejects.toMatchObject({ code: 'unauthorized' }); + }); + }); + + test('retains failed acquisition cleanup until terminal drain retries it', async () => { + await fixture(async ({ agent, capabilities, host, runner, url }) => { + const windows = jest.spyOn(host, 'windows').mockRejectedValueOnce(new Error('injected acquisition failure')); + const close = jest.spyOn(host, 'closeApplication').mockRejectedValueOnce(new Error('injected lease cleanup failure')); + await expect(agent.newSession(capabilities)).rejects.toMatchObject({ code: 'session not created' }); + await expect(acquire(runner, url)).rejects.toMatchObject({ data: { reason: 'target-busy' } }); + await agent.revokeConnection(); + const grant = await acquire(runner, url); + const session = await grant.newSession(capabilities); + await session.delete(); + expect(close).toHaveBeenCalledTimes(3); + expect(windows).toHaveBeenCalledTimes(2); + }); + }); + + test('drains a late opening lease and excludes competing handoffs before admitting a worker', async () => { + await fixture(async ({ agent, capabilities, host, newAgent, operator, runner, url }) => { + const entered = barrier(); + const finish = barrier(); + const attach = host.attach.bind(host); + jest.spyOn(host, 'attach').mockImplementationOnce(async (target) => { + entered.release(); + await finish.pending; + return attach(target); + }); + const creating = expect(agent.newSession(capabilities)).rejects.toMatchObject({ code: 'session not created' }); + await entered.pending; + let completed = false; + const handoff = acquire(operator, url, 'preempt').then((value) => { + completed = true; + return value; + }); + const contender = await newAgent(); + await expect(acquire(runner, url)).rejects.toMatchObject({ data: { reason: 'target-busy' } }); + await expect(contender.newSession(capabilities)).rejects.toMatchObject({ data: { reason: 'target-busy' } }); + expect(completed).toBe(false); + finish.release(); + const reservation = await handoff; + await creating; + expect(host.actions).toEqual(expect.arrayContaining([expect.objectContaining({ type: 'close-application', ownership: 'attached' })])); + const worker = await reservation.delegateConnection(); + await expect(worker.newSession({ alwaysMatch: { 'furn:target': 'attached', 'furn:launchMode': 'launch' } })).rejects.toMatchObject({ + code: 'session not created', + }); + await expect(worker.newSession(capabilities)).resolves.toBeDefined(); + }); + }); + + test('failed preemption does not authorize a new controller and its terminal cleanup is retryable', async () => { + await fixture(async ({ agent, capabilities, host, operator, runner, url }) => { + await agent.newSession(capabilities); + jest.spyOn(host, 'releaseActions').mockRejectedValueOnce(attachFailureEffect(new Error('injected release failure'), 'none')); + await expect(acquire(operator, url, 'preempt')).rejects.toBeDefined(); + await expect(acquire(runner, url)).rejects.toMatchObject({ data: { reason: 'target-busy' } }); + await agent.revokeConnection(); + await expect(acquire(runner, url)).resolves.toBeDefined(); + }); + }); +}); diff --git a/packages/agentic/desktop-test/AGENTS.md b/packages/agentic/desktop-test/AGENTS.md index 38b90d9e12..5189299fe0 100644 --- a/packages/agentic/desktop-test/AGENTS.md +++ b/packages/agentic/desktop-test/AGENTS.md @@ -19,6 +19,10 @@ and bounded structured output contract. config fields and invalid app/suite references. - WDIO owns suite sessions. Do not create competing WebDriver sessions from hooks. +- Attached agent-service runners borrow the existing claim/listener. Keep + target reservations until every worker grant is drain-confirmed; a crash or + revocation callback alone never releases them. Credentials stay out of WDIO + capabilities/generated settings and are registered for redaction before use. - Cleanup only resources whose ownership was recorded; interactive runs use the per-user workload lock. - Never count skipped capabilities, empty work, or incomplete work as passing diff --git a/packages/agentic/desktop-test/README.md b/packages/agentic/desktop-test/README.md index 1e1a15a591..c7f5c00792 100644 --- a/packages/agentic/desktop-test/README.md +++ b/packages/agentic/desktop-test/README.md @@ -61,6 +61,9 @@ when working on the package. - App targets and suites share one structural configuration schema. - WDIO owns suite sessions, globals, hooks, grouped execution, retries, and framework assertions. +- Generic externally owned apps support drain-confirmed MCP/WDIO handoff via + `test --attach-agent`; see [runner ownership](references/wdio-suites.md#reuse-an-attached-agent-service). + Storybook handoff and retained session launches remain unsupported. - The generic harness supervises services, workers, and the driver; session-owned app launch and the Storybook lifecycle takeover are transitional (see [lifecycle](references/lifecycle.md)). diff --git a/packages/agentic/desktop-test/references/commands.md b/packages/agentic/desktop-test/references/commands.md index c25b3a4f9e..e03c7a765e 100644 --- a/packages/agentic/desktop-test/references/commands.md +++ b/packages/agentic/desktop-test/references/commands.md @@ -16,6 +16,7 @@ workspace's declared script. | `prep`, `bundle`, `build`, `run` | Run the selected target adapter stage | | `test --suite --list` | Resolve groups without launching a target | | `test --suite ` | Run the selected WDIO suite | +| `test --suite --attach-agent` | Reuse a generic external-app agent service | | `test --suite --input ` | Run one input lane (`accessible` or `physical`) | | `serve --reporter json` | Start the real driver listener and required services | | `serve --agent` | Also require grants and publish an agent descriptor | @@ -58,8 +59,16 @@ Desktop Driver MCP adapter reads. The record is `agent-connection.json` under the platform's private storage root unless `--descriptor ` or `--root ` say otherwise; the readiness document adds its `agentDescriptor` name, and the grant never appears in output or artifacts. -Stopping `serve` removes the descriptor before revoking the grant. Ordinary -WDIO clients cannot connect to a service started with `--agent`. A target +Stopping `serve` removes the descriptor before revoking and draining the grant. +Ordinary unauthenticated WDIO clients cannot connect to a service started with +`--agent`. Generic external-app targets additionally publish a separate +`runner-connection.json` for `test --attach-agent`. Runner reuse takes no second +claim or listener; it defaults to busy and requires explicit relinquish or +operator-authorized preemption. `serve --runner-descriptor ` selects its +record; `serve --allow-runner-preempt` opts into preemption authority. On +`test`, `--descriptor`, `--root`, and `--preempt` require `--attach-agent`. +See [attached runner handoff](wdio-suites.md#reuse-an-attached-agent-service) +for the drain barrier and unsupported phases. A target served by an imported integration accepts `--agent` only when the integration declares `supportsAgent`; its `run` receives the descriptor and root and publishes the connection from the process that serves the driver. Storybook diff --git a/packages/agentic/desktop-test/references/current-status.md b/packages/agentic/desktop-test/references/current-status.md index 2b58da4e1a..64a07d4100 100644 --- a/packages/agentic/desktop-test/references/current-status.md +++ b/packages/agentic/desktop-test/references/current-status.md @@ -21,16 +21,16 @@ and its sub-issues. | -------------------------------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | App/suite configuration and standalone installed-app use | Done | Storybook is optional; runtime boundary and standalone-consumer contracts reject React, React Native, and Storybook imports/dependencies. Executable config is trusted operator code, not a sandbox. | | Native service and helper composition | Done | Generic supervisor and supervised Storybook child use the same real `driverService` factory, helper policy, authority and settled close. | -| Standalone WDIO/Jasmine execution | Done | WDIO owns its session. Storybook CSF callbacks use a runner-owned session borrowed by a Node worker. | +| Standalone WDIO/Jasmine execution | Done | WDIO owns its session; generic external-app `--attach-agent` reuses the service's claim/listener with a parent-owned reservation and drain-confirmed worker children. Real WDIO deterministic handoff/crash/lost-response contracts pass. Storybook CSF callbacks retain their runner-owned borrowed-session exception. Native endpoint qualification remains separate. | | Required services, reporting, structured results | Done | One collector; bounded JSON/NDJSON summaries. Service-ready is not app/context-ready. | -| `serve --agent` descriptor publication | Done | Protects driver routes; no automatic build/launch from MCP. Normal WDIO cannot use the agent-served path yet. | +| `serve --agent` descriptor publication | Done | Protects driver routes; no automatic build/launch from MCP. Generic external-app services publish separate private agent and runner descriptors; unauthenticated WDIO remains refused. Runner preemption authority requires explicit operator opt-in. | | Cleanup failure fence | Done, conservative | Failed or timed-out disposal retains the claim with an exact resource generation and unresolved effect; manual inspection is required. No automatic reclaim. | | Complete lifecycle settlement | Done, conservative | Guarded work settles before successful close; late acquisitions are disposed; required-service failures stop dependants; owned resources dispose in reverse order. | | Typed generic resource contributions | Done | Static contribution plans validate IDs/dependencies and record readiness, generation, identity, ownership, and settled disposal through public `resources` and `ownership` subpaths. | | Durable quarantine wiring | Done | Both native service paths use owner-only storage and exact-incarnation probes; restart is not recovery proof. Live endpoint qualification remains separate. | | Supervisor crash containment | Work in progress | Cooperative Node service lifelines and correlated service readiness implemented; portable parent/wrapper crash, late-spawn, failed-withdrawal and stalled-child contracts. Live macOS survivors remain unqualified; no automatic reclaim. Windows: the helper's Job Object ends an unpackaged app it launched when the helper dies (qualified with REX); packaged launches are `uncontained` and survive in a fenced claim. Non-detached children are hard-terminated with a crashed owner (observed A/B), so the three crash-disposal contracts are skipped there. | | Storybook resource contributions | Work in progress | Current integration substitutes its CLI lifecycle; retain the channel/driver child until relocation is justified and qualified. | -| MCP/WDIO handoff | Work in progress | Must await drain/deletion; attached-app handoff is independent of future supervisor-owned launch. | +| MCP/WDIO handoff | Work in progress | Generic attach-only phase implemented: bounded busy, explicit relinquish/operator-authorized preemption, settled deletion, target reservation, crash-drained worker grants, and fresh-session resources. Deterministic tests pass. Storybook phase waits for F9; retained launched apps and Windows/Win32 native qualification remain separate. | | Supervisor-owned app lifetime | Work in progress | Current session-owned launch/externally owned attach semantics remain; never silently retain or terminate another owner's app. | | Cross-service/multi-target claims | Work in progress | Configured aliases now have distinct registered IDs while retaining physical identity; exact-application exclusion and input-mutex qualification remain open. | | Bounded persisted run/status/artifact queries | Done | Harness-local read service/client; atomic snapshots, terminal evidence, scoped expiring grants, bounded pages and allowlisted opaque artifact IDs. No management transport. | diff --git a/packages/agentic/desktop-test/references/lifecycle.md b/packages/agentic/desktop-test/references/lifecycle.md index 59817017f9..752eb8b68f 100644 --- a/packages/agentic/desktop-test/references/lifecycle.md +++ b/packages/agentic/desktop-test/references/lifecycle.md @@ -143,6 +143,17 @@ than taking over CLI output and supervision. Management/run API schemas belong h Attached-app MCP/WDIO handoff can be developed independently of supervisor-owned native launch, but both must wait for session drain and preserve external apps. +Generic attach-only services now support an explicit +[MCP/WDIO handoff](wdio-suites.md#reuse-an-attached-agent-service). The serving +supervisor retains its claim, listener, host, and required services. The +standalone runner borrows that service but owns its reserved grant subtree; +WDIO owns each worker session. Worker completion, crash, cancellation, and +parent cleanup all await terminal grant drain before releasing the reservation. +Old sessions and their references/cursors/proofs/artifact links are never +transferred. A cleanup failure is not permission to start a new controller. +This does not retain a session-launched application or migrate Storybook's +transitional lifecycle. + Configured target aliases receive distinct registered IDs while retaining the same physical `instanceId` when they resolve to the same application identity. Do not use an alias as proof that two targets are physically distinct. diff --git a/packages/agentic/desktop-test/references/wdio-suites.md b/packages/agentic/desktop-test/references/wdio-suites.md index d169d5c40f..09687be0af 100644 --- a/packages/agentic/desktop-test/references/wdio-suites.md +++ b/packages/agentic/desktop-test/references/wdio-suites.md @@ -18,6 +18,55 @@ unmatched pattern is an error rather than silently reduced coverage. Keep target definitions in the application config and reference them from suite workspaces. +## Reuse an attached agent service + +A generic target with `host.ownership: "external"` can share one protected +service between MCP and standalone WDIO, without restarting the listener or +acquiring another machine workload claim: + +```sh +yarn desktop-test --target macos serve --agent +# In the MCP connection: desktop_session { "action": "relinquish" } +yarn desktop-test --target macos test --suite regression --attach-agent +# Once the runner exits, the same MCP connection may open a fresh session. +``` + +The service publishes a separate private `runner-connection.json` alongside +the driver-only `agent-connection.json`. Both verify the native listener's +identity before sending credentials. `test --attach-agent --descriptor +--root ` selects the existing runner record; the flags are discovery +inputs, not arbitrary service URLs. `serve --runner-descriptor ` changes +its name. Direct library hosts can disable publication with `agent.runner: +false`. + +The default handoff is an immediate `target-busy` result. MCP must explicitly +relinquish and await confirmed cleanup. Alternatively an operator may start +the service with `--allow-runner-preempt`, then request `test --attach-agent +--preempt`; the flag alone confers no authority. Preemption fences new +commands, cancels and awaits in-flight native work, and confirms session +deletion before a target-scoped, attach-only runner grant is returned. A +failed drain retains the busy fence; revocation notification is not proof. + +The parent runner keeps the reservation between WDIO workers, hands each +worker a distinct driver-only child via private process transport, and awaits +its terminal drain in `onWorkerEnd` before another group or retry starts. +A worker crash therefore cannot leave a session for the next controller to +reuse. Runner shutdown drains the entire connection subtree even when a +worker or acquisition response was lost. Credentials travel only in private +descriptors, environment, and request closures; never put them in hooks, +capabilities, settings, generated configuration, command arguments, or logs. + +Each handoff opens a new session: old element references, observation cursors, +uniqueness proofs, and session-qualified artifact links expire. There is no +cross-MCP-connection resume or automatic replay. The externally owned app is +left running. Supervisor-retained launches and Storybook integration handoff +are not supported by this phase. The CSF exception is unchanged: its runner +owns the session and its isolated Node worker only borrows it. + +An attached runner cannot change an existing host's input masks. In particular, +`--input accessible` still refuses a host that has not already disabled +keyboard, physical click, and wheel; do not weaken that check to reuse a service. + ## Native page adapter Use the desktop driver's registered WebdriverIO binding for native diff --git a/packages/agentic/desktop-test/src/agentConnection.ts b/packages/agentic/desktop-test/src/agentConnection.ts index 12f4653700..b8b3f4d8dd 100644 --- a/packages/agentic/desktop-test/src/agentConnection.ts +++ b/packages/agentic/desktop-test/src/agentConnection.ts @@ -7,8 +7,9 @@ import { describeWindowsService, publishConnectionDescriptor, } from '@fluentui-react-native/desktop-driver/authority'; -import type { DesktopAuthority } from '@fluentui-react-native/desktop-driver/authority'; +import type { DesktopAuthority, GrantScope } from '@fluentui-react-native/desktop-driver/authority'; import type { ResolvedDesktopTarget } from './config.js'; +import { defaultRunnerDescriptor } from './wdio/runnerConnection.js'; /** The record name the Desktop Driver MCP adapter reads by default. */ export const defaultAgentDescriptor = 'agent-connection.json'; @@ -18,6 +19,8 @@ export type DesktopAgentConnectionOptions = { descriptor?: string; /** The private storage root, which the adapter reads with `--root`. Defaults to the platform location. */ root?: string; + /** Generic attach-only services also publish a separate workload connection; Storybook handoff is not supported. */ + runner?: false | { descriptor?: string; allowPreempt?: boolean }; /** Replaces native publication; tests supply it. Returns the descriptor's removal. */ publish?: PublishDesktopAgentConnection; }; @@ -88,6 +91,7 @@ export type PublishDesktopAgentOptions = { signal?: AbortSignal; /** Bounds publication by its owner's lifetime, such as a service scope's guard. */ guard?: (work: Promise) => Promise; + role?: 'agent' | 'runner'; }; /** @@ -96,8 +100,22 @@ export type PublishDesktopAgentOptions = { */ export async function publishDesktopAgent(options: PublishDesktopAgentOptions): Promise { const { artifact, authority, connection, target } = options; - const descriptor = connection.descriptor ?? defaultAgentDescriptor; - const grant = authority.issue({ label: 'agent', targets: [target.id] }); + if ( + connection.runner !== undefined && + connection.runner !== false && + (typeof connection.runner !== 'object' || + connection.runner === null || + (connection.runner.allowPreempt !== undefined && typeof connection.runner.allowPreempt !== 'boolean')) + ) { + throw new TypeError('Runner publication requires options with an optional boolean allowPreempt.'); + } + const runner = options.role === 'runner'; + const runnerOptions = connection.runner || undefined; + const descriptor = runner ? (runnerOptions?.descriptor ?? defaultRunnerDescriptor) : (connection.descriptor ?? defaultAgentDescriptor); + const scopes: GrantScope[] = runner + ? ['driver-control', 'declared-workload', ...(runnerOptions?.allowPreempt ? (['operator-recovery'] as const) : [])] + : ['driver-control']; + const grant = authority.issue({ label: runner ? 'wdio' : 'agent', targets: [target.id], scopes }); options.addSecret?.(grant.token); const publish = connection.publish ?? createNativeAgentPublisher(artifact ? async () => artifact : undefined); let publication: Promise<() => Promise> | undefined; @@ -117,7 +135,7 @@ export async function publishDesktopAgent(options: PublishDesktopAgentOptions): remove = await (options.guard ? options.guard(publication) : publication); } catch (error) { void publication?.then((late) => late()).catch(() => undefined); - authority.revoke(grant.id); + await authority.revokeAndDrain(grant.id); throw error; } let withdrawal: Promise | undefined; @@ -128,8 +146,11 @@ export async function publishDesktopAgent(options: PublishDesktopAgentOptions): try { await remove(); } finally { - authority.revoke(grant.id); + await authority.revokeAndDrain(grant.id); } - })()), + })().catch((error: unknown) => { + withdrawal = undefined; + throw error; + })), }); } diff --git a/packages/agentic/desktop-test/src/cli.ts b/packages/agentic/desktop-test/src/cli.ts index f675bcf789..a442eb6c3b 100644 --- a/packages/agentic/desktop-test/src/cli.ts +++ b/packages/agentic/desktop-test/src/cli.ts @@ -25,8 +25,18 @@ import { DesktopStopRequested } from './services.js'; import { handleDesktopInterrupts } from './signals.js'; import { DesktopSupervisor } from './supervisor.js'; import { assertDesktopSuiteSupportsInput, runDesktopWdioSuite } from './wdio/run.js'; +import { connectDesktopRunner, defaultRunnerDescriptor } from './wdio/runnerConnection.js'; -type AgentFlags = { agent?: boolean; descriptor?: string; root?: string }; +type AgentFlags = { agent?: boolean; descriptor?: string; root?: string; runnerDescriptor?: string; allowRunnerPreempt?: boolean }; +type TestFlags = { + suite: string; + list?: boolean; + input?: string; + attachAgent?: boolean; + descriptor?: string; + root?: string; + preempt?: boolean; +}; // How long an integration may take to unwind after a stop request or scope failure before the scope is closed anyway. const INTEGRATION_STOP_TIMEOUT_MS = 60_000; @@ -56,18 +66,24 @@ export async function runDesktopTestCli(argv = process.argv): Promise { .option('--agent', 'require a grant on every route and publish one agent connection descriptor') .option('--descriptor ', `agent connection descriptor record name (default ${defaultAgentDescriptor})`) .option('--root ', 'private storage root for the agent descriptor; defaults to the platform location') + .option('--runner-descriptor ', `attach-only runner descriptor record (default ${defaultRunnerDescriptor})`) + .option('--allow-runner-preempt', 'explicitly grant the attach-only runner operator authority to preempt an existing controller') .action((options: AgentFlags) => execute('serve', undefined, options)); program .command('test') .requiredOption('--suite ', 'configured suite') .option('--list', 'resolve exact spec groups without launching') + .option('--attach-agent', 'reuse an existing generic attach-only agent service; never acquire a second workload claim') + .option('--descriptor ', `existing runner descriptor (default ${defaultRunnerDescriptor}); requires --attach-agent`) + .option('--root ', 'existing descriptor private storage root; requires --attach-agent') + .option('--preempt', 'request operator-authorized preemption instead of the default busy result; requires --attach-agent') .addOption( new Option( '--input ', `run one input lane: accessible (keyboard, physical click and wheel masked) or physical; falls back to ${desktopInputEnvironment}`, ).choices([...desktopInputLanes]), ) - .action((options: { suite: string; list?: boolean; input?: string }) => execute('test', options)); + .action((options: TestFlags) => execute('test', options)); let reporterMode: ResolvedDesktopReporterMode = 'plain'; let reporterResolutionError: unknown; try { @@ -96,7 +112,7 @@ export async function runDesktopTestCli(argv = process.argv): Promise { async function execute( action: DesktopStage | 'build-driver' | 'serve' | 'test', - test?: { suite: string; list?: boolean; input?: string }, + test?: TestFlags, agentFlags: AgentFlags = {}, ): Promise { const options = program.opts<{ @@ -132,6 +148,14 @@ export async function runDesktopTestCli(argv = process.argv): Promise { if (options.paper) target = selectDesktopRenderer(target, 'paper'); const suite = test ? config.config.suites?.[test.suite] : undefined; if (test && !suite) throw Object.assign(new Error(`Unknown suite "${test.suite}".`), { code: 'config' }); + if (test && !test.attachAgent && (test.descriptor !== undefined || test.root !== undefined || test.preempt)) { + throw Object.assign(new TypeError('--descriptor, --root and --preempt require --attach-agent.'), { code: 'config' }); + } + if (test?.attachAgent && (suite?.kind !== 'wdio' || target.host.ownership !== 'external' || config.config.integration)) { + throw Object.assign(new TypeError('--attach-agent requires a generic standalone WDIO suite and an external target.'), { + code: 'config', + }); + } if (test?.list && test.input !== undefined) { throw Object.assign(new TypeError('--list resolves spec files, which do not depend on the input lane; omit --input.'), { code: 'config', @@ -156,8 +180,14 @@ export async function runDesktopTestCli(argv = process.argv): Promise { : undefined; const input = requestedLane?.input; if (suite?.kind === 'wdio') assertDesktopSuiteSupportsInput(test!.suite, suite, input); - if (!agentFlags.agent && (agentFlags.descriptor !== undefined || agentFlags.root !== undefined)) - throw Object.assign(new TypeError('--descriptor and --root require --agent.'), { code: 'config' }); + if ( + !agentFlags.agent && + (agentFlags.descriptor !== undefined || + agentFlags.root !== undefined || + agentFlags.runnerDescriptor !== undefined || + agentFlags.allowRunnerPreempt) + ) + throw Object.assign(new TypeError('Descriptor and runner publication options require --agent.'), { code: 'config' }); // An integration serves through its own lifecycle, so it must publish the agent connection itself. if (agentFlags.agent && config.config.integration?.run && config.config.integration.supportsAgent !== true) throw Object.assign( @@ -170,6 +200,9 @@ export async function runDesktopTestCli(argv = process.argv): Promise { ? { ...(agentFlags.descriptor === undefined ? {} : { descriptor: agentFlags.descriptor }), ...(agentFlags.root === undefined ? {} : { root: agentFlags.root }), + ...(agentFlags.runnerDescriptor === undefined && !agentFlags.allowRunnerPreempt + ? {} + : { runner: { descriptor: agentFlags.runnerDescriptor, allowPreempt: agentFlags.allowRunnerPreempt } }), } : undefined; let ready = false; @@ -182,7 +215,9 @@ export async function runDesktopTestCli(argv = process.argv): Promise { optional: suite?.kind === 'wdio' && suite.optional, context: { endpoint: target.endpoint, renderer: target.host.renderer }, // The supervisor starts the driver, and starts it masked for an accessible lane. - ...(requestedLane ? { lane: createDesktopInputLane(requestedLane.input, requestedLane.source, 'host-masked') } : {}), + ...(requestedLane + ? { lane: createDesktopInputLane(requestedLane.input, requestedLane.source, test?.attachAgent ? undefined : 'host-masked') } + : {}), reporterMode, eventLevel, suppressFinalOutput: action === 'serve' ? () => ready : false, @@ -224,6 +259,10 @@ export async function runDesktopTestCli(argv = process.argv): Promise { kind: 'diagnostic', payload: { level: 'info', message: `Verified helper ${artifact.artifactId} at ${artifact.executablePath}` }, }); + } else if (test?.attachAgent && suite?.kind === 'wdio') { + await owner.open(false); + const connection = await connectDesktopRunner({ descriptor: test.descriptor, root: test.root }); + await runDesktopWdioSuite(owner, config, test.suite, input, { connection, preempt: test.preempt }); } else if (config.config.integration?.run && suite?.kind !== 'wdio') { await owner.open(['test', 'serve', 'run'].includes(action)); await owner.startResources(); diff --git a/packages/agentic/desktop-test/src/driverService.ts b/packages/agentic/desktop-test/src/driverService.ts index 34641f9cad..d2f2a2535c 100644 --- a/packages/agentic/desktop-test/src/driverService.ts +++ b/packages/agentic/desktop-test/src/driverService.ts @@ -38,7 +38,7 @@ export type DesktopServiceOptions = { ) => Promise | undefined>; }; -export type DesktopService = DesktopDriverServer & { readonly agentDescriptor?: string }; +export type DesktopService = DesktopDriverServer & { readonly agentDescriptor?: string; readonly runnerDescriptor?: string }; export type NativeDesktopServiceOptions = Omit & DesktopServiceOptions & { @@ -68,6 +68,7 @@ export async function startDesktopService( const authority = options.agent ? new DesktopAuthority() : undefined; let server: DesktopDriverServer | undefined; let publication: DesktopAgentPublication | undefined; + let runnerPublication: DesktopAgentPublication | undefined; try { const recovery = await resolveRecovery(options, artifact); // Only the declared story context is taken: an integration cannot replace the target's identity or launch modes. @@ -99,14 +100,41 @@ export async function startDesktopService( signal: options.signal, guard: options.guard, }); + if ( + options.agent.runner !== false && + !target.storyOrchestrator && + target.launchModes?.length === 1 && + target.launchModes[0] === 'attach' + ) { + runnerPublication = await publishDesktopAgent({ + authority: authority!, + connection: options.agent, + target: { endpoint: options.target.endpoint, id: options.target.id }, + url: server.url, + role: 'runner', + ...(artifact ? { artifact } : {}), + addSecret: options.addSecret, + signal: options.signal, + guard: options.guard, + }); + } else if (options.agent.runner && !runnerPublication) { + throw new Error('Runner descriptors require a generic attach-only target.'); + } } } catch (error) { - try { - if (server) await server.close(); - else await desktopHost.dispose(); - } catch (cleanup) { - throw new AggregateError([error, cleanup], 'Desktop service startup and disposal failed.'); + const failures: unknown[] = []; + for (const step of [ + async () => runnerPublication?.withdraw(), + async () => publication?.withdraw(), + () => (server ? server.close() : desktopHost.dispose()), + ]) { + try { + await step(); + } catch (cleanup) { + failures.push(cleanup); + } } + if (failures.length) throw new AggregateError([error, ...failures], 'Desktop service startup and disposal failed.'); throw error; } const listening = server; @@ -116,10 +144,13 @@ export async function startDesktopService( get agentDescriptor() { return closing ? undefined : publication?.descriptor; }, + get runnerDescriptor() { + return closing ? undefined : runnerPublication?.descriptor; + }, close: () => (closing ??= (async () => { const failures: unknown[] = []; - for (const step of [async () => publication?.withdraw(), () => listening.close()]) { + for (const step of [async () => runnerPublication?.withdraw(), async () => publication?.withdraw(), () => listening.close()]) { try { await step(); } catch (error) { diff --git a/packages/agentic/desktop-test/src/wdio/configuration.ts b/packages/agentic/desktop-test/src/wdio/configuration.ts index bac03c8fb8..3c9c9d0ce4 100644 --- a/packages/agentic/desktop-test/src/wdio/configuration.ts +++ b/packages/agentic/desktop-test/src/wdio/configuration.ts @@ -1,6 +1,6 @@ import { pathToFileURL } from 'node:url'; import { randomUUID } from 'node:crypto'; -import { installDesktopWebdriverCommands } from '@fluentui-react-native/desktop-driver/wdio'; +import { desktopWebdriverGrantOptions, installDesktopWebdriverCommands } from '@fluentui-react-native/desktop-driver/wdio'; import type { DesktopEndpoint, DesktopRenderer } from '@fluentui-react-native/desktop-driver'; import { resolveDesktopClickMode } from '@fluentui-react-native/desktop-driver/authoring'; import type { DesktopReadiness } from '../config.js'; @@ -9,13 +9,14 @@ import { desktopTestSelectionFromEnvironment } from '../jasmine/selection.js'; import { currentDesktopJasmineEnvironment, selectDesktopSpecs } from '../jasmine/specSelection.js'; import { connectReportProducer } from '../reporting/transport.js'; import { DesktopReportedError, serializeReportError } from '../reporting/protocol.js'; +import { requestDesktopRunnerWorker, runnerBrokerUrlEnvironment, runnerWorkerTokenEnvironment } from './runnerConnection.js'; export type DesktopWdioSettings = { url: string; targetId: string; endpoint: DesktopEndpoint; renderer?: DesktopRenderer; - ownership: 'session' | 'external'; + ownership: 'session' | 'supervisor' | 'external'; specs: (string | string[])[]; framework: string; reporter: string; @@ -32,6 +33,8 @@ type Hook = (...args: unknown[]) => unknown; const hookNames = [ 'onPrepare', 'onComplete', + 'onWorkerStart', + 'onWorkerEnd', 'beforeSession', 'afterSession', 'before', @@ -62,6 +65,8 @@ export async function createDesktopWdioConfiguration(settings: DesktopWdioSettin const readiness = settings.readiness; // The launcher read the selection too, so a malformed one fails the run before any worker starts. const selection = desktopTestSelectionFromEnvironment(); + const broker = process.env[runnerBrokerUrlEnvironment] !== undefined; + const grant = process.env[runnerWorkerTokenEnvironment]; const configuration = { ...hooks, runner: 'local', @@ -72,6 +77,7 @@ export async function createDesktopWdioConfiguration(settings: DesktopWdioSettin bail: settings.bail, specFileRetries: settings.specFileRetries, logLevel: 'error', + ...(grant ? desktopWebdriverGrantOptions(grant) : {}), // The framework adapters report a spec file that fails to import only as a warning and then run zero tests; keep that one visible. logLevels: { '@wdio/jasmine-framework': 'warn', '@wdio/mocha-framework': 'warn' }, hostname: address.hostname, @@ -101,12 +107,23 @@ export async function createDesktopWdioConfiguration(settings: DesktopWdioSettin 'furn:inputMode': selection?.lane ?? 'compatibility', }, ], + onWorkerStart: async (id: string, ...args: unknown[]) => { + delete process.env[runnerWorkerTokenEnvironment]; + if (broker) process.env[runnerWorkerTokenEnvironment] = await requestDesktopRunnerWorker('POST', id); + await hooks.onWorkerStart?.(id, ...args); + }, + onWorkerEnd: async (id: string, ...args: unknown[]) => { + delete process.env[runnerWorkerTokenEnvironment]; + if (broker) await requestDesktopRunnerWorker('DELETE', id); + await hooks.onWorkerEnd?.(id, ...args); + }, beforeSession: async (...args: unknown[]) => { + if (broker && !grant) throw new Error('The WDIO worker has no scoped Desktop Driver authority.'); process.env.E2ETEST_PLATFORM = settings.endpoint; await hooks.beforeSession?.(...args); }, before: async (capabilities: unknown, specs: unknown, browser: WebdriverIO.Browser) => { - installDesktopWebdriverCommands(browser, settings.url); + installDesktopWebdriverCommands(browser, settings.url, { grant }); // Before the application is touched: a lane that cannot vouch for its tests must stop here. if (settings.framework === 'jasmine') { selectDesktopSpecs( diff --git a/packages/agentic/desktop-test/src/wdio/run.ts b/packages/agentic/desktop-test/src/wdio/run.ts index 43d452222f..3f760d151a 100644 --- a/packages/agentic/desktop-test/src/wdio/run.ts +++ b/packages/agentic/desktop-test/src/wdio/run.ts @@ -9,6 +9,8 @@ import { desktopTestSelectionEnvironment, serializeDesktopTestSelection } from ' import type { DesktopSupervisor } from '../supervisor.js'; import type { DesktopWdioSettings } from './configuration.js'; import { wdioGroupId } from './identity.js'; +import { createDesktopRunnerBroker } from './runnerConnection.js'; +import type { DesktopRunnerConnection } from './runnerConnection.js'; const desktopWdioSettingsEnvironment = 'DESKTOP_WDIO_SETTINGS'; @@ -36,10 +38,14 @@ export async function runDesktopWdioSuite( config: ResolvedDesktopConfig, suiteName: string, input?: DesktopInputLane, + attached?: { connection: DesktopRunnerConnection; preempt?: boolean }, ): Promise { const suite = config.config.suites?.[suiteName]; if (!suite || suite.kind !== 'wdio') throw new Error(`No standalone WDIO suite "${suiteName}" is configured.`); assertDesktopSuiteSupportsInput(suiteName, suite, input); + if (attached && supervisor.target.host.ownership !== 'external') { + throw new Error('An existing agent service can run only an external, attach-only target.'); + } const specs = resolveDesktopSpecs(suite, supervisor.target.endpoint, config.projectRoot); const workIds: string[] = []; for (const selected of specs) { @@ -62,7 +68,7 @@ export async function runDesktopWdioSuite( modulePath('@wdio/local-runner'); modulePath(`@wdio/${suite.framework}-framework`); const settings: DesktopWdioSettings = { - url: supervisor.server.url, + url: attached?.connection.url ?? supervisor.server.url, targetId: supervisor.target.id, endpoint: supervisor.target.endpoint, renderer: supervisor.target.host.renderer, @@ -93,28 +99,48 @@ export async function runDesktopWdioSuite( testScopes: config.config.testScopes, values: resolveDesktopTestScope(config.testScopes, supervisor.target), }); - await supervisor.worker( - { - command: process.execPath, - args: [fileURLToPath(new URL('../../config/launcher.cjs', import.meta.url)), pathToFileURL(launcher).href, configPath], - cwd: config.projectRoot, - env: { - E2ETEST_PLATFORM: supervisor.target.endpoint, - NATIVE_TESTING: 'false', - [desktopTestSelectionEnvironment]: selection, - [desktopWdioSettingsEnvironment]: settingsPath, + const broker = attached + ? await createDesktopRunnerBroker({ + ...attached, + targetId: supervisor.target.id, + addSecret: (secret) => supervisor.reporter.addSecret(secret), + signal: supervisor.services.controller.signal, + }) + : undefined; + let failure: unknown; + try { + await supervisor.worker( + { + command: process.execPath, + args: [fileURLToPath(new URL('../../config/launcher.cjs', import.meta.url)), pathToFileURL(launcher).href, configPath], + cwd: config.projectRoot, + env: { + E2ETEST_PLATFORM: supervisor.target.endpoint, + NATIVE_TESTING: 'false', + [desktopTestSelectionEnvironment]: selection, + [desktopWdioSettingsEnvironment]: settingsPath, + ...broker?.environment, + }, + label: `WDIO ${suiteName} (${supervisor.target.endpoint})`, + }, + suiteDeadline(suite, specs.length), + async (code) => { + await supervisor.reporter.assertSuccessful(workIds, suite.optional); + if (code !== 0) + throw new Error( + `WDIO suite "${suiteName}" exited with code ${code} without a corresponding engine failure; see the retained worker log.`, + ); }, - label: `WDIO ${suiteName} (${supervisor.target.endpoint})`, - }, - suiteDeadline(suite, specs.length), - async (code) => { - await supervisor.reporter.assertSuccessful(workIds, suite.optional); - if (code !== 0) - throw new Error( - `WDIO suite "${suiteName}" exited with code ${code} without a corresponding engine failure; see the retained worker log.`, - ); - }, - ); + ); + } catch (error) { + failure = error; + } + try { + await broker?.close(); + } catch (cleanup) { + throw new AggregateError(failure === undefined ? [cleanup] : [failure, cleanup], 'WDIO runner cleanup failed.'); + } + if (failure !== undefined) throw failure; } function suiteDeadline(suite: DesktopWdioSuite, groups: number): number { diff --git a/packages/agentic/desktop-test/src/wdio/runner.contract.cjs b/packages/agentic/desktop-test/src/wdio/runner.contract.cjs new file mode 100644 index 0000000000..96b7b3209b --- /dev/null +++ b/packages/agentic/desktop-test/src/wdio/runner.contract.cjs @@ -0,0 +1,180 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { pathToFileURL } = require('node:url'); + +async function main() { + const mode = process.argv[2]; + const packageRoot = path.resolve(__dirname, '../..'); + const lib = (name) => pathToFileURL(path.join(packageRoot, 'lib', name)).href; + const { DesktopSupervisor } = await import(lib('supervisor.js')); + const { DesktopRunReporter } = await import(lib('reporting/collector.js')); + const { runDesktopWdioSuite } = await import(lib('wdio/run.js')); + const { createDesktopRunnerBroker } = await import(lib('wdio/runnerConnection.js')); + const { resolveDesktopScopeDomains } = await import('@fluentui-react-native/desktop-driver/authoring'); + const { FakeDesktopHost } = await import('@fluentui-react-native/desktop-driver/testing'); + const { createDesktopDriverClient } = await import('@fluentui-react-native/desktop-driver/client'); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'runner-contract-')); + let output = ''; + const sink = { write: (chunk) => ((output += chunk.toString()), true) }; + const reporting = (kind) => new DesktopRunReporter({ artifactsRoot: root, kind, output: sink, errorOutput: sink, summary: false }); + const serviceReport = reporting('lifecycle'); + const run = reporting('tests'); + const target = { + endpoint: 'win32', + host: { kind: 'installed', ownership: 'external', application: { windowTitle: 'fake' }, renderer: 'paper' }, + id: 'runner-attached', + instanceId: 'runner-attached', + projectRoot: packageRoot, + configPath: path.join(root, 'desktop.config.mjs'), + }; + const records = new Map(); + const storage = { + read: async (name) => records.get(name), + publish: async (name, value) => { + assert(!records.has(name)); + records.set(name, value); + }, + remove: async (name, value) => records.get(name) === value && records.delete(name), + }; + const published = []; + const host = new FakeDesktopHost({ endpoint: 'win32', features: { boundedObservation: true } }); + let attached = 0; + let closed = 0; + const attach = host.attach.bind(host); + host.attach = (...args) => { + attached++; + return attach(...args); + }; + const close = host.closeApplication.bind(host); + host.closeApplication = (...args) => { + closed++; + return close(...args); + }; + const locks = path.join(root, 'locks'); + const service = new DesktopSupervisor({ + target, + reporter: serviceReport, + recovery: { storage }, + lockDirectory: locks, + hostFactory: async () => host, + agent: { + publish: async (connection) => { + published.push(connection); + return async () => undefined; + }, + }, + }); + const runner = new DesktopSupervisor({ + target, + reporter: run, + lockDirectory: locks, + hostFactory: async () => { + throw new Error('An attached runner must never start a second host.'); + }, + }); + const spec = path.join(root, 'spec.mjs'); + fs.writeFileSync( + spec, + mode === 'worker-crash' + ? "describe('crashed worker', () => { it('leaves its session for the parent to drain', () => { process.exit(17); }); });" + : [ + "describe('protected runner', () => {", + " it('uses the worker-owned native session', async () => {", + ' const grant = process.env.DESKTOP_WDIO_WORKER_TOKEN;', + " expect(typeof grant).toBe('string');", + ' expect(JSON.stringify(browser.options)).not.toContain(grant);', + ' expect(JSON.stringify(browser.capabilities)).not.toContain(grant);', + " console.log('redaction probe', grant, process.env.DESKTOP_WDIO_BROKER_TOKEN);", + " expect(await (await browser.$('~button-primary')).isDisplayed()).toBeTrue();", + ' });', + '});', + ].join('\n'), + ); + const config = { + config: { schemaVersion: 1, targets: { win32: target }, suites: { fixture: { kind: 'wdio', framework: 'jasmine', specs: [spec] } } }, + projectRoot: packageRoot, + configPath: target.configPath, + targets: { win32: target }, + reporting: { artifactsRoot: root }, + testScopes: resolveDesktopScopeDomains(), + }; + let client; + try { + await service.start(); + assert.equal(published.length, 2, 'generic attach-only services publish separate agent and workload connections'); + const agent = published.find(({ descriptor }) => descriptor === 'agent-connection.json'); + const workload = published.find(({ descriptor }) => descriptor === 'runner-connection.json'); + assert(agent && workload); + client = createDesktopDriverClient({ grant: agent.grant, url: agent.url }); + const controller = await client.delegateConnection(); + const capabilities = { alwaysMatch: { 'furn:target': target.id, 'furn:launchMode': 'attach' } }; + const session = await controller.newSession(capabilities); + const connection = { client: createDesktopDriverClient({ grant: workload.grant, url: workload.url }), url: workload.url }; + await runner.open(false); + assert(fs.existsSync(path.join(locks, 'interactive.lock')), 'the service keeps the one machine claim'); + await assert.rejects(createDesktopRunnerBroker({ connection, targetId: target.id, addSecret: (secret) => run.addSecret(secret) }), { + code: 'session not created', + message: /already has an active session/, + }); + assert.equal(attached, 1, 'a busy request never opens another native session'); + await controller.revokeConnection(); + const fetcher = globalThis.fetch; + globalThis.fetch = async (input, init) => { + const response = await fetcher(input, init); + if (String(input).endsWith('/furn/grants/runner')) { + await response.body?.cancel(); + throw new Error('injected runner acquisition response loss'); + } + return response; + }; + try { + await assert.rejects(createDesktopRunnerBroker({ connection, targetId: target.id, addSecret: (secret) => run.addSecret(secret) })); + } finally { + globalThis.fetch = fetcher; + } + let failure; + try { + await runDesktopWdioSuite(runner, config, 'fixture', undefined, { connection }); + } catch (error) { + failure = error; + if (mode === 'worker-crash') run.fail(error); + } + if (mode === 'worker-crash') assert(failure, 'a crashed worker must fail its run'); + else assert.equal(failure, undefined, `${failure?.stack}\n${output}`); + assert.equal(attached, 2); + assert.equal(closed, 2, 'the parent must confirm deletion even when the worker crashes'); + await assert.rejects(session.getWindowHandles(), { code: 'unauthorized' }); + const next = await (await client.delegateConnection()).newSession(capabilities); + await next.delete(); + assert(fs.existsSync(path.join(locks, 'interactive.lock')), 'runner cleanup must not release the service claim'); + await runner.close(); + const result = await run.finish(); + assert.equal(result.status, mode === 'worker-crash' ? 'failed' : 'passed', JSON.stringify(result)); + const grantPattern = /fdg1\.[0-9a-f]{32}\.[A-Za-z0-9_-]{43}/; + assert(!grantPattern.test(output), 'no grant reaches reporter output'); + let retained = ''; + for (const entry of fs.readdirSync(run.root, { recursive: true, withFileTypes: true })) { + if (entry.isFile()) { + const content = fs.readFileSync(path.join(entry.parentPath, entry.name), 'utf8'); + retained += content; + assert(!grantPattern.test(content), `no grant reaches ${entry.name}`); + } + } + if (mode === 'handoff') assert(retained.includes('redaction probe [REDACTED]'), 'deliberately printed worker credentials are redacted'); + await service.close(); + await serviceReport.finish(); + assert(!fs.existsSync(path.join(locks, 'interactive.lock'))); + process.stdout.write(JSON.stringify({ mode, passed: true })); + } finally { + await runner.close(); + await service.close(); + fs.rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/packages/agentic/desktop-test/src/wdio/runner.test.ts b/packages/agentic/desktop-test/src/wdio/runner.test.ts new file mode 100644 index 0000000000..17ff0588f7 --- /dev/null +++ b/packages/agentic/desktop-test/src/wdio/runner.test.ts @@ -0,0 +1,32 @@ +import { spawn } from 'node:child_process'; +import path from 'node:path'; + +describe('protected standalone WDIO runner', () => { + test.each(['handoff', 'worker-crash'] as const)( + '%s drains scoped worker authority without persisting credentials', + async (mode) => { + const result = await new Promise((resolve, reject) => { + const child = spawn(process.execPath, ['--throw-deprecation', path.join(__dirname, 'runner.contract.cjs'), mode], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + child.stdout.on('data', (data: Buffer) => stdout.push(data)); + child.stderr.on('data', (data: Buffer) => stderr.push(data)); + child.once('error', reject); + child.once('close', (code) => { + if (code !== 0) reject(new Error(Buffer.concat(stderr).toString('utf8') || Buffer.concat(stdout).toString('utf8'))); + else { + try { + resolve(JSON.parse(Buffer.concat(stdout).toString('utf8'))); + } catch (error) { + reject(error); + } + } + }); + }); + expect(result).toEqual({ mode, passed: true }); + }, + 120_000, + ); +}); diff --git a/packages/agentic/desktop-test/src/wdio/runnerConnection.ts b/packages/agentic/desktop-test/src/wdio/runnerConnection.ts new file mode 100644 index 0000000000..5bf5fb612b --- /dev/null +++ b/packages/agentic/desktop-test/src/wdio/runnerConnection.ts @@ -0,0 +1,218 @@ +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; +import { createServer } from 'node:http'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { resolveNativeDesktopDriver } from '@fluentui-react-native/desktop-driver/native'; +import { + createMacOSPrivateStorage, + createMacOSServiceVerifier, + createWindowsPrivateStorage, + createWindowsServiceVerifier, + readConnectionDescriptor, +} from '@fluentui-react-native/desktop-driver/authority'; +import { createDesktopDriverClient } from '@fluentui-react-native/desktop-driver/client'; +import type { DesktopDriverClient } from '@fluentui-react-native/desktop-driver/client'; + +export const defaultRunnerDescriptor = 'runner-connection.json'; +export const runnerBrokerUrlEnvironment = 'DESKTOP_WDIO_BROKER_URL'; +export const runnerBrokerTokenEnvironment = 'DESKTOP_WDIO_BROKER_TOKEN'; +export const runnerWorkerTokenEnvironment = 'DESKTOP_WDIO_WORKER_TOKEN'; + +export type DesktopRunnerConnection = { client: DesktopDriverClient; url: string }; + +/** Reuses the existing service and its workload claim. Verification precedes sending any descriptor credential. */ +export async function connectDesktopRunner(options: { descriptor?: string; root?: string }): Promise { + try { + const platform = process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'windows' : undefined; + if (!platform) throw new Error(); + const artifact = await resolveNativeDesktopDriver({ buildPolicy: 'never', platform }); + const storage = + platform === 'macos' + ? await createMacOSPrivateStorage(...(options.root === undefined ? [] : [options.root])) + : await createWindowsPrivateStorage({ artifact, ...(options.root === undefined ? {} : { root: options.root }) }); + const verify = platform === 'macos' ? createMacOSServiceVerifier({ artifact }) : createWindowsServiceVerifier({ artifact }); + const descriptor = await readConnectionDescriptor(storage, options.descriptor ?? defaultRunnerDescriptor, verify); + return { client: createDesktopDriverClient({ grant: descriptor.grant, url: descriptor.url }), url: descriptor.url }; + } catch { + // Private storage and verification failures may contain descriptor credentials. + throw new Error('Runner discovery failed. Verify the private descriptor, native helper, and service identity on macOS or Windows.'); + } +} + +export type DesktopRunnerBroker = { + /** Private process transport, never WDIO configuration or capabilities. Register these values with the log redactor. */ + readonly environment: Readonly>; + close(): Promise; +}; + +/** + * The parent owns every grant even if the launcher, worker, hook, or an acquisition response is lost. Workers receive + * only their own attenuated driver grant, and the launcher awaits its drain before scheduling the next worker. + */ +export async function createDesktopRunnerBroker(options: { + connection: DesktopRunnerConnection; + targetId: string; + preempt?: boolean; + addSecret: (secret: string) => void; + signal?: AbortSignal; +}): Promise { + const { connection, addSecret, signal } = options; + const control = await delegate(connection.client, '/furn/grants/runner-connection', {}, connection.url, addSecret); + try { + const runner = await delegate( + control, + '/furn/grants/runner', + { target: options.targetId, handoff: options.preempt ? 'preempt' : 'busy' }, + connection.url, + addSecret, + signal, + ); + const token = randomBytes(32).toString('base64url'); + addSecret(token); + const digest = hash(token); + const workers = new Map(); + let closed = false; + let port = 0; + let pending: Promise = Promise.resolve(); + const server = createServer((request, response) => { + if ( + closed || + request.headers.origin !== undefined || + request.headers.host !== `127.0.0.1:${port}` || + typeof request.headers.authorization !== 'string' || + request.headers.authorization.length > 128 || + !request.headers.authorization.startsWith('Bearer ') || + !timingSafeEqual(hash(request.headers.authorization.slice(7)), digest) + ) { + answer(response, 401, { error: 'Desktop runner authority is unavailable.' }); + return; + } + const work = pending.catch(() => undefined).then(() => dispatch(request, response)); + pending = work.catch(() => { + answer(response, 503, { error: 'Desktop runner worker drain is not confirmed.' }); + }); + }); + server.on('error', () => undefined); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.off('error', reject); + resolve(); + }); + }); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Desktop runner broker did not open a loopback listener.'); + port = address.port; + // eslint-disable-next-line @microsoft/sdl/no-insecure-url -- Private, bearer-authenticated loopback transport. + const url = `http://127.0.0.1:${address.port}`; + let closing: Promise | undefined; + let listenerClosed = false; + return { + environment: Object.freeze({ [runnerBrokerUrlEnvironment]: url, [runnerBrokerTokenEnvironment]: token }), + close: () => { + closed = true; + return (closing ??= (async () => { + try { + await pending; + await control.revokeConnection(); + } finally { + if (!listenerClosed) { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + listenerClosed = true; + } + } + })().catch((error: unknown) => { + closing = undefined; + throw error; + })); + }, + }; + + async function dispatch(request: IncomingMessage, response: ServerResponse): Promise { + if (closed) { + answer(response, 401, { error: 'Desktop runner authority is unavailable.' }); + return; + } + const match = /^\/workers\/([0-9]{1,8}-[0-9]{1,8})$/.exec(request.url ?? ''); + if (!match || (request.method !== 'POST' && request.method !== 'DELETE')) { + answer(response, 400, { error: 'Unsupported desktop runner worker request.' }); + return; + } + const id = match[1]; + if (request.method === 'POST') { + if (workers.size) throw new Error('The previous worker has not drained.'); + const value = await runner.request<{ token: string }>('POST', '/furn/grants/connection', {}); + const child = issuedClient(value, connection.url, addSecret); + workers.set(id, child); + answer(response, 200, value); + } else { + const child = workers.get(id); + if (child) { + await child.revokeConnection(); + workers.delete(id); + } + answer(response, 200, null); + } + } + } catch (error) { + try { + await control.revokeConnection(); + } catch (cleanup) { + throw new AggregateError([error, cleanup], 'Runner acquisition and drain failed.'); + } + throw error; + } +} + +async function delegate( + client: DesktopDriverClient, + route: string, + body: object, + url: string, + addSecret: (secret: string) => void, + signal?: AbortSignal, +): Promise { + const value = await client.request<{ token: string }>('POST', route, body, signal); + return issuedClient(value, url, addSecret); +} + +function issuedClient(value: { token: string }, url: string, addSecret: (secret: string) => void): DesktopDriverClient { + if (typeof value?.token !== 'string' || !/^fdg1\.[0-9a-f]{32}\.[A-Za-z0-9_-]{43}$/.test(value.token)) { + throw new Error('The service did not issue runner authority.'); + } + addSecret(value.token); + return createDesktopDriverClient({ grant: value.token, url }); +} + +function hash(value: string): Buffer { + return createHash('sha256').update(value).digest(); +} + +function answer(response: ServerResponse, status: number, value: unknown): void { + if (response.destroyed || response.writableEnded) return; + response.writeHead(status, { 'Content-Type': 'application/json', Connection: 'close' }); + response.end(JSON.stringify(value)); +} + +/** Launcher hooks only. The worker's grant is never put in the configuration object WDIO serializes. */ +export async function requestDesktopRunnerWorker(method: 'POST' | 'DELETE', id: string): Promise { + const url = process.env[runnerBrokerUrlEnvironment]; + const token = process.env[runnerBrokerTokenEnvironment]; + if (!url || !token || !/^[0-9]{1,8}-[0-9]{1,8}$/.test(id)) throw new Error('Desktop runner worker authority is unavailable.'); + let value: unknown; + try { + const response = await fetch(`${url}/workers/${id}`, { + method, + headers: { Authorization: `Bearer ${token}` }, + redirect: 'error', + }); + if (!response.ok) throw new Error('Worker authority failed.'); + value = await response.json(); + } catch { + throw new Error('Desktop runner worker authority or drain is not confirmed.'); + } + if (method === 'DELETE') return undefined; + if (typeof value !== 'object' || value === null || !('token' in value) || typeof value.token !== 'string') { + throw new Error('Desktop runner worker authority is unavailable.'); + } + return value.token; +} From b203739f171e0f996b4b76347238cdff174ad70e Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 12 Nov 2026 02:12:45 +0000 Subject: [PATCH 02/23] Add opt-in native descriptor-path runner handoff qualification Use an explicitly provisioned AppKit fixture, real native identity verification, MCP SDK, and standalone WDIO. Retain scoped evidence for busy admission, relinquish, stale resources, crashed worker drain, credential confinement, and exact external-app preservation. Native scenarios remain opt-in and unsupported endpoints are not counted as passes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../references/native-qualification.md | 38 +++ .../src/wdio/nativeRunner.contract.cjs | 253 ++++++++++++++++++ .../src/wdio/nativeRunner.contract.test.ts | 38 +++ 3 files changed, 329 insertions(+) create mode 100644 packages/agentic/desktop-test/src/wdio/nativeRunner.contract.cjs create mode 100644 packages/agentic/desktop-test/src/wdio/nativeRunner.contract.test.ts diff --git a/packages/agentic/desktop-test/references/native-qualification.md b/packages/agentic/desktop-test/references/native-qualification.md index 7d0ae7dfbd..1c862b50e4 100644 --- a/packages/agentic/desktop-test/references/native-qualification.md +++ b/packages/agentic/desktop-test/references/native-qualification.md @@ -168,6 +168,44 @@ The F3/F4/F5 cases are integration gates, not claims about the F15 baseline. Run them only on the combined source that contains those tracks. Until then, record them as **Not run**. +## Generic attach-only MCP/WDIO handoff + +This is independent of the Storybook procedure and its renderer/context gates. +Provision the plain AppKit ownership fixture separately, with bundle identifier +`com.microsoft.furn.application-ownership-fixture`, executable +`Contents/MacOS/fixture`, `--title`/`--ready-file` arguments, and accessibility +identifiers `ownership-fixture-increment`/`ownership-fixture-count`. It must be +built and signed explicitly, never downloaded or compiled by this test. +Build the changed driver, harness, and MCP workspaces first. Provision the +verified stable-signed helper and run noninteractive permission diagnostics, +as above. + +Under the exclusive native-desktop lock, from the repository root: + +```sh +FURN_NATIVE_DRIVER_TEST=1 \ +FURN_NATIVE_HANDOFF_APP_PATH=/path/to/FurnOwnershipFixture.app \ +yarn workspace @fluentui-react-native/desktop-test test nativeRunner.contract --runInBand +``` + +The opt-in contract uses the actual private descriptors and native listener +verification, MCP SDK transport, native attach sessions, and WDIO/Jasmine. +It checks bounded busy, explicit relinquish, refusal of MCP while the runner +owns the target, fresh sessions/resources, worker-crash drain, credential +confinement, and preservation of the fixture's exact PID through service +shutdown. Only the test that spawned the fixture terminates it afterward. +Capture/resource-link coverage is explicitly marked unsupported when the +endpoint refuses capture. + +The normal handoff runs first; a failure stops before the crash scenario. +Retain `artifacts/desktop/f11/native-*/evidence.json` and the run reports. Do not +copy the private descriptor directory into shared artifacts. Record source +revision, fixture revision, helper identity, selected/executed cases, native +permission failures, cleanup, and skipped/not-run scenarios separately. +Absent the explicit opt-ins, the test is skipped, not qualified. This contract +does not qualify Windows/Win32, Storybook handoff, retained launches, physical +input, cross-connection resume, or replay. + ## Windows Fabric and Win32 Paper The procedure is the same with these substitutions. `--paper` is invalid on diff --git a/packages/agentic/desktop-test/src/wdio/nativeRunner.contract.cjs b/packages/agentic/desktop-test/src/wdio/nativeRunner.contract.cjs new file mode 100644 index 0000000000..b4bba0c80d --- /dev/null +++ b/packages/agentic/desktop-test/src/wdio/nativeRunner.contract.cjs @@ -0,0 +1,253 @@ +const assert = require('node:assert/strict'); +const { spawn, spawnSync } = require('node:child_process'); +const { once } = require('node:events'); +const fs = require('node:fs'); +const { createRequire } = require('node:module'); +const path = require('node:path'); +const { setTimeout: delay } = require('node:timers/promises'); +const { pathToFileURL } = require('node:url'); + +async function main() { + const mode = process.argv[2]; + const packageRoot = path.resolve(__dirname, '../..'); + const repositoryRoot = path.resolve(packageRoot, '../../..'); + const lib = (name) => pathToFileURL(path.join(packageRoot, 'lib', name)).href; + const mcpRoot = path.resolve(packageRoot, '../desktop-driver-mcp'); + const mcpRequire = createRequire(path.join(mcpRoot, 'package.json')); + const { DesktopSupervisor } = await import(lib('supervisor.js')); + const { DesktopRunReporter } = await import(lib('reporting/collector.js')); + const { runDesktopWdioSuite } = await import(lib('wdio/run.js')); + const { connectDesktopRunner, createDesktopRunnerBroker } = await import(lib('wdio/runnerConnection.js')); + const { resolveDesktopScopeDomains } = await import('@fluentui-react-native/desktop-driver/authoring'); + const { connectWithDescriptor } = await import(pathToFileURL(path.join(mcpRoot, 'lib/connection.js')).href); + const { createDesktopDriverMcpServer } = await import(pathToFileURL(path.join(mcpRoot, 'lib/server.js')).href); + const { Client } = await import(pathToFileURL(mcpRequire.resolve('@modelcontextprotocol/sdk/client/index.js')).href); + const { InMemoryTransport } = await import(pathToFileURL(mcpRequire.resolve('@modelcontextprotocol/sdk/inMemory.js')).href); + const parent = path.join(repositoryRoot, 'artifacts/desktop/f11'); + fs.mkdirSync(parent, { recursive: true, mode: 0o700 }); + const root = fs.mkdtempSync(path.join(parent, `native-${mode}-`)); + const evidence = { + mode, + passed: false, + revision: spawnSync('git', ['rev-parse', 'HEAD'], { cwd: repositoryRoot, encoding: 'utf8' }).stdout.trim(), + fixture: 'com.microsoft.furn.application-ownership-fixture', + fixturePreserved: false, + descriptorsRemoved: false, + claimReleased: false, + }; + let output = ''; + const sink = { write: (chunk) => ((output += chunk.toString()), true) }; + const reporting = (kind) => new DesktopRunReporter({ artifactsRoot: root, kind, output: sink, errorOutput: sink, summary: false }); + const serviceReport = reporting('lifecycle'); + const run = reporting('tests'); + const privateRoot = path.join(root, 'private'); + const locks = path.join(root, 'locks'); + const ready = path.join(root, 'fixture.ready'); + const fixture = spawn( + path.join(fs.realpathSync(process.env.FURN_NATIVE_HANDOFF_APP_PATH), 'Contents/MacOS/fixture'), + ['--title', path.basename(root), '--ready-file', ready], + { stdio: 'ignore' }, + ); + fixture.once('error', () => undefined); + const target = { + endpoint: 'macos', + host: { + kind: 'installed', + ownership: 'external', + application: { bundleIdentifier: evidence.fixture, windowTitle: path.basename(root) }, + }, + id: 'native-runner-attached', + instanceId: 'native-runner-attached', + projectRoot: packageRoot, + configPath: path.join(root, 'desktop.config.mjs'), + }; + const service = new DesktopSupervisor({ + target, + reporter: serviceReport, + lockDirectory: locks, + input: 'accessible', + recovery: { root: privateRoot }, + agent: { root: privateRoot }, + }); + const runner = new DesktopSupervisor({ target, reporter: run, lockDirectory: locks }); + const modelResults = []; + let client; + let server; + let cleanup; + const shutdown = () => + (cleanup ??= (async () => { + const failures = []; + for (const close of [async () => client?.close(), async () => server?.close(), () => runner.close(), () => service.close()]) { + try { + await close(); + } catch (error) { + failures.push(error); + } + } + evidence.fixturePreserved = fixture.exitCode === null && fixture.signalCode === null; + evidence.descriptorsRemoved = + !fs.existsSync(path.join(privateRoot, 'agent-connection.json')) && !fs.existsSync(path.join(privateRoot, 'runner-connection.json')); + evidence.claimReleased = !fs.existsSync(path.join(locks, 'interactive.lock')); + if (fixture.exitCode === null && fixture.signalCode === null) { + const exited = once(fixture, 'exit'); + fixture.kill('SIGTERM'); + await exited; + } + evidence.fixtureExited = fixture.exitCode !== null || fixture.signalCode !== null; + for (const report of [run, serviceReport]) { + try { + await report.finish(); + } catch (error) { + failures.push(error); + } + } + fs.writeFileSync(path.join(root, 'evidence.json'), JSON.stringify(evidence, null, 2), { mode: 0o600 }); + if (failures.length) throw new AggregateError(failures, 'Native handoff fixture cleanup failed.'); + })()); + process.once('SIGTERM', () => void shutdown().finally(() => process.exit(143))); + try { + await waitFor(ready, fixture); + assert.equal(Number(fs.readFileSync(ready, 'utf8')), fixture.pid); + await service.start(); + evidence.descriptorPublished = true; + const connection = await connectDesktopRunner({ root: privateRoot }); + server = createDesktopDriverMcpServer({ + connect: connectWithDescriptor({ descriptor: 'agent-connection.json', root: privateRoot }), + version: '0.0.0-native-contract', + }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + client = new Client({ name: 'native-handoff-contract', version: '1.0.0' }); + await client.connect(clientTransport); + const call = async (name, args = {}) => { + const result = await client.callTool({ name, arguments: args }); + modelResults.push(result); + return result; + }; + const json = (reply) => { + assert(!reply.isError, JSON.stringify(reply)); + return JSON.parse(reply.content.find(({ type }) => type === 'text').text); + }; + const original = json(await call('desktop_session', { action: 'open', target: target.id })); + assert.equal(service.server.sessions.get(original.id).lease.processId, fixture.pid); + evidence.nativeSessionOpened = true; + const observed = json(await call('desktop_observe', { observe: { budget: { maxNodes: 1 } } })).result; + assert(observed.next); + const found = json(await call('desktop_observe', { find: { automationId: 'ownership-fixture-increment' } })).result; + assert.equal(found.matches.length, 1); + const stale = found.matches[0].ref; + const capture = await call('desktop_capture'); + const artifact = capture.content.find(({ type }) => type === 'resource_link'); + evidence.capture = artifact ? 'passed' : 'unsupported'; + await runner.open(false); + await assert.rejects(createDesktopRunnerBroker({ connection, targetId: target.id, addSecret: (secret) => run.addSecret(secret) }), { + code: 'session not created', + }); + evidence.defaultBusy = true; + assert.deepEqual(json(await call('desktop_session', { action: 'relinquish' })), { relinquished: true }); + const spec = path.join(root, 'spec.mjs'); + process.env.FURN_NATIVE_HANDOFF_RUN_ROOT = root; + process.env.FURN_NATIVE_HANDOFF_JASMINE_MODULE = lib('jasmine/index.js'); + fs.writeFileSync( + spec, + mode === 'worker-crash' + ? [ + 'const { desktopIt } = await import(process.env.FURN_NATIVE_HANDOFF_JASMINE_MODULE);', + "desktopIt('leaves its native session for parent drain', () => { process.exit(17); });", + ].join('\n') + : [ + "import fs from 'node:fs';", + "import path from 'node:path';", + 'const { desktopIt } = await import(process.env.FURN_NATIVE_HANDOFF_JASMINE_MODULE);', + "describe('native scoped runner', () => {", + " desktopIt('holds exclusive control and uses accessibility input', { requires: ['accessibility-click'] }, async () => {", + ' const root = process.env.FURN_NATIVE_HANDOFF_RUN_ROOT;', + " fs.writeFileSync(path.join(root, 'worker.ready'), 'ready');", + " await browser.waitUntil(() => fs.existsSync(path.join(root, 'worker.continue')), { timeout: 30000 });", + " const count = await browser.$('~ownership-fixture-count');", + ' const before = await count.getText();', + " await (await browser.$('~ownership-fixture-increment')).click();", + ' expect(await count.getText()).not.toBe(before);', + " console.log('redaction probe', process.env.DESKTOP_WDIO_WORKER_TOKEN, process.env.DESKTOP_WDIO_BROKER_TOKEN);", + ' });', + '});', + ].join('\n'), + ); + const config = { + config: { schemaVersion: 1, targets: { macos: target }, suites: { fixture: { kind: 'wdio', framework: 'jasmine', specs: [spec] } } }, + projectRoot: packageRoot, + configPath: target.configPath, + targets: { macos: target }, + reporting: { artifactsRoot: root }, + testScopes: resolveDesktopScopeDomains(), + }; + const completed = runDesktopWdioSuite(runner, config, 'fixture', 'accessible', { connection }).then( + () => undefined, + (error) => error, + ); + if (mode === 'handoff') { + await waitFor(path.join(root, 'worker.ready'), fixture); + const busy = await call('desktop_session', { action: 'open', target: target.id }); + assert(busy.isError); + assert.equal(JSON.parse(busy.content[0].text).reason, 'target-busy'); + fs.writeFileSync(path.join(root, 'worker.continue'), 'continue'); + evidence.noConcurrentMcp = true; + } + const failure = await completed; + if (mode === 'worker-crash') { + assert(failure, 'the crashed native worker must fail its run'); + run.fail(failure); + } else assert.equal(failure, undefined, failure?.stack); + evidence.workerDrained = true; + const fresh = json(await call('desktop_session', { action: 'open', target: target.id })); + assert.notEqual(fresh.id, original.id); + const checked = await call('desktop_check', { ref: stale, expect: { enabled: true } }); + assert(checked.isError); + const page = await call('desktop_observe', { read: { cursor: observed.next } }); + assert(page.isError); + assert.equal(JSON.parse(page.content[0].text).reason, 'observation-cursor-expired'); + const acted = json(await call('desktop_act', { steps: [{ act: { action: 'activate', ref: stale, sample: found.sample.id } }] })); + assert.equal(acted.result.status, 'stopped'); + if (artifact) await assert.rejects(client.readResource({ uri: artifact.uri })); + evidence.staleResourcesRejected = true; + json(await call('desktop_session', { action: 'close' })); + await runner.close(); + assert(fs.existsSync(path.join(locks, 'interactive.lock'))); + const grantPattern = /fdg1\.[0-9a-f]{32}\.[A-Za-z0-9_-]{43}/; + assert(!grantPattern.test(JSON.stringify(modelResults))); + assert(!grantPattern.test(output)); + const result = await run.finish(); + assert.equal(result.status, mode === 'worker-crash' ? 'failed' : 'passed'); + for (const entry of fs.readdirSync(run.root, { recursive: true, withFileTypes: true })) { + if (entry.isFile()) assert(!grantPattern.test(fs.readFileSync(path.join(entry.parentPath, entry.name), 'utf8'))); + } + evidence.credentialsConfined = true; + } catch (error) { + const message = error instanceof Error ? error.message : 'Native handoff fixture failed.'; + evidence.error = message; + run.fail(error); + throw new Error(`Native ${mode} qualification failed; evidence ${path.join(root, 'evidence.json')}: ${message}`); + } finally { + await shutdown(); + } + assert(evidence.fixturePreserved, 'the externally owned fixture must survive all service cleanup'); + assert(evidence.fixtureExited && evidence.descriptorsRemoved && evidence.claimReleased); + evidence.passed = true; + fs.writeFileSync(path.join(root, 'evidence.json'), JSON.stringify(evidence, null, 2), { mode: 0o600 }); + process.stdout.write(JSON.stringify({ mode, passed: true, evidence: path.join(root, 'evidence.json') })); +} + +async function waitFor(file, fixture) { + const deadline = Date.now() + 60_000; + while (!fs.existsSync(file)) { + if (fixture.exitCode !== null || fixture.signalCode !== null) + throw new Error('The native application fixture exited before readiness.'); + if (Date.now() >= deadline) throw new Error('The native application fixture did not report readiness.'); + await delay(50); + } +} + +main().catch((error) => { + console.error(error.message); + process.exitCode = 1; +}); diff --git a/packages/agentic/desktop-test/src/wdio/nativeRunner.contract.test.ts b/packages/agentic/desktop-test/src/wdio/nativeRunner.contract.test.ts new file mode 100644 index 0000000000..262645e2be --- /dev/null +++ b/packages/agentic/desktop-test/src/wdio/nativeRunner.contract.test.ts @@ -0,0 +1,38 @@ +import { spawn } from 'node:child_process'; +import path from 'node:path'; + +const nativeTest = + process.platform === 'darwin' && process.env.FURN_NATIVE_DRIVER_TEST === '1' && process.env.FURN_NATIVE_HANDOFF_APP_PATH + ? test + : test.skip; + +nativeTest( + 'real private-descriptor MCP/WDIO handoff preserves the attached app and drains a crashed worker', + async () => { + for (const mode of ['handoff', 'worker-crash']) { + const result = await new Promise((resolve, reject) => { + const child = spawn(process.execPath, [path.join(__dirname, 'nativeRunner.contract.cjs'), mode], { + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 180_000, + }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + child.stdout.on('data', (data: Buffer) => stdout.push(data)); + child.stderr.on('data', (data: Buffer) => stderr.push(data)); + child.once('error', reject); + child.once('close', (code) => { + if (code !== 0) reject(new Error(Buffer.concat(stderr).toString('utf8') || Buffer.concat(stdout).toString('utf8'))); + else { + try { + resolve(JSON.parse(Buffer.concat(stdout).toString('utf8'))); + } catch (error) { + reject(error); + } + } + }); + }); + expect(result).toMatchObject({ mode, passed: true }); + } + }, + 420_000, +); From 74d129e35ca7cfb27f6eda6014d9596f0d7c76e5 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 12 Nov 2026 02:18:07 +0000 Subject: [PATCH 03/23] Classify invalid runner handoff input before reserving authority Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/server/createDesktopDriverServer.ts | 4 +++- .../desktop-driver/src/server/runnerHandoff.test.ts | 10 ++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/agentic/desktop-driver/src/server/createDesktopDriverServer.ts b/packages/agentic/desktop-driver/src/server/createDesktopDriverServer.ts index 754e154acf..79e7c3f0ba 100644 --- a/packages/agentic/desktop-driver/src/server/createDesktopDriverServer.ts +++ b/packages/agentic/desktop-driver/src/server/createDesktopDriverServer.ts @@ -401,7 +401,9 @@ async function route(context: RouteContext, sessions: SessionManager, targets: T } if (method === 'POST' && segments[2] === 'runner') { const request = requireObject(body, 'Runner grant'); - rejectUnknownKeys(request, { target: true, handoff: true }, 'runner grant input'); + if (Object.keys(request).some((key) => key !== 'target' && key !== 'handoff')) { + throw invalidArgument('Runner grants accept only target and handoff.'); + } if ( typeof request.target !== 'string' || (request.handoff !== undefined && request.handoff !== 'busy' && request.handoff !== 'preempt') diff --git a/packages/agentic/desktop-driver/src/server/runnerHandoff.test.ts b/packages/agentic/desktop-driver/src/server/runnerHandoff.test.ts index addd024de9..b4403eaeba 100644 --- a/packages/agentic/desktop-driver/src/server/runnerHandoff.test.ts +++ b/packages/agentic/desktop-driver/src/server/runnerHandoff.test.ts @@ -65,6 +65,16 @@ async function acquire(client: DesktopDriverClient, url: string, handoff?: 'pree } describe('attach-only runner handoff', () => { + test('rejects unknown runner input without issuing or reserving authority', async () => { + await fixture(async ({ agent, capabilities, runner }) => { + await expect(runner.request('POST', '/furn/grants/runner', { target: 'attached', unexpected: true })).rejects.toMatchObject({ + code: 'invalid argument', + }); + const session = await agent.newSession(capabilities); + await session.delete(); + }); + }); + test('defaults to bounded busy and never gives the MCP child workload or preemption authority', async () => { await fixture(async ({ agent, capabilities, operator, runner, url }) => { const session = await agent.newSession(capabilities); From c2748ca99767bcd068bde2aea8a2cff1debca7b4 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 12 Nov 2026 02:24:42 +0000 Subject: [PATCH 04/23] Record scoped runner handoff qualification and native permission limits Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../references/current-status.md | 26 ++++++++++ .../references/current-status.md | 27 +++++++++++ .../desktop-test/references/current-status.md | 48 +++++++++++++++++++ 3 files changed, 101 insertions(+) diff --git a/packages/agentic/desktop-driver-mcp/references/current-status.md b/packages/agentic/desktop-driver-mcp/references/current-status.md index ca57ce4fab..b6d6036e56 100644 --- a/packages/agentic/desktop-driver-mcp/references/current-status.md +++ b/packages/agentic/desktop-driver-mcp/references/current-status.md @@ -339,3 +339,29 @@ out before the observed bridge retirement; the subsequent attempt waited for retirement. No raw image or grant is retained in this reference. Driver shutdown, app cleanup, and the failed Win32 app-run reports are recorded in `packages/agentic/desktop-test/references/current-status.md`. + +## F11 explicit relinquish — 2026-11-12 + +Source `0ebf8f453` includes explicit `desktop_session` relinquish with an +immediate admission fence, cancellation, and awaited connection-subtree +drain. A failed drain retains retryable cleanup responsibility. A fresh +session after relinquish rejects old references, cursors, proofs and +artifact links; no cross-connection resume or replay is added. +Package format, lint, build, and all 31 tests passed, including +deterministic resource invalidation, late/lost acquisition and failed-drain +cases. These are portable semantics, not native qualification. + +The real private-descriptor AppKit consumer at `87bc8b710` reached verified +discovery but failed native session creation on macOS 27.2.1 arm64: +`The target does not support any click input mode.` The stable-signed +helper doctor reported Accessibility/PostEvent `false`; no privacy settings +were changed. Native relinquish/handoff and worker-crash drain did not run. +Both descriptors and the workload claim were removed; the externally owned +fixture survived service shutdown and only its spawning test stopped it. + +Exact commands, revisions, one corrected-fixture retry, cleanup evidence +and local artifact locators are in +`@fluentui-react-native/desktop-test/references/current-status.md`. +Windows/Win32 native handoff is not covered by the existing PR smoke/E2E +selection and remains not run. Storybook integration and retained-launch +ownership remain separate follow-up phases. diff --git a/packages/agentic/desktop-driver/references/current-status.md b/packages/agentic/desktop-driver/references/current-status.md index 578e4dd5b8..14130f2e49 100644 --- a/packages/agentic/desktop-driver/references/current-status.md +++ b/packages/agentic/desktop-driver/references/current-status.md @@ -473,3 +473,30 @@ after the disconnect, so fresh-preview fencing remains Partial. The first probe timed out after 10 seconds; bridge retirement occurred about 19 seconds after injection. A later attempt waited for retirement and retained the same Partial result. Physical Ctrl+C and DPI changes were not exercised. + +## F11 scoped runner handoff — 2026-11-12 + +Source `0ebf8f453` implements the generic attach-only phase: attenuated +runner/controller grants, bounded busy by default, operator-authorized +preemption, and target reservation only after in-flight acquisition, +session deletion, and controller-subtree drain settle. Revocation callbacks +alone are not a drain barrier. Failed cleanup remains fenced and retryable; +unknown runner input is rejected before granting or reserving authority. +Driver package format, lint, build, and tests passed (1,229 passed, +66 skipped); the focused handoff suite passed seven cases. + +Native selection at `87bc8b710` on macOS 27.2.1 arm64 passed 27 identity +and backend cases, with 15 Windows cases skipped. The stable-signed helper +doctor was ready but reported Accessibility/PostEvent `false`. The real +private-descriptor AppKit consumer then failed session creation with no +supported click input mode. Native handoff and worker-crash drain remain +unqualified; no permissions were changed. Cleanup preserved the externally +owned fixture through service shutdown, removed both descriptors and +released the claim before its spawning test ended that exact process. + +Exact commands, helper/fixture revisions, the initial stale-fixture failure, +one corrected-fixture retry, cleanup evidence and artifact locators are in +`@fluentui-react-native/desktop-test/references/current-status.md`. +Windows/Win32 native handoff is not selected by existing PR smoke/E2E jobs +and remains not run. Storybook integration, retained launches, and +cross-connection resume/replay are not implemented by this phase. diff --git a/packages/agentic/desktop-test/references/current-status.md b/packages/agentic/desktop-test/references/current-status.md index 64a07d4100..a4edbd75f0 100644 --- a/packages/agentic/desktop-test/references/current-status.md +++ b/packages/agentic/desktop-test/references/current-status.md @@ -1026,3 +1026,51 @@ macOS Accessibility block above remains unchanged; no local smoke was repeated. `pwsh` is unavailable locally, so its parser check remains not run; the actual scripts executed in these CI lanes. Keep the PR draft until the documentation follow-up's CI passes and the coordinator changes its status. + +## F11 generic attach-only handoff — 2026-11-12 + +Implementation source: `0ebf8f453` (including `4aafeeabb` and `87bc8b710`). +Native selection ran at `87bc8b710`. This is the generic attached-application +phase of [#4448](https://github.com/microsoft/fluentui-react-native/issues/4448), +not the blocked Storybook integration or retained-launch phases. + +**Confirmed, portable:** driver, harness, and MCP package format, lint, and +build checks passed. Driver tests passed 1,229 with 66 skipped; MCP tests +passed 31. The real WDIO deterministic normal-handoff and worker-crash tests +passed, including a lost runner-grant response, target exclusion, final +subtree drain, fresh-session resources, and credential confinement. +The final unlocked harness run passed 380, failed two default-claim CLI +contracts, and skipped two; the lane failure explicitly reported another +workload holding `interactive.lock`. One focused retry of the two failed +CLI contracts under the shared native lock passed both (28 unselected +cases skipped). This collision is not native handoff evidence. + +**Observed, native:** macOS 27.2.1, arm64, plain AppKit fixture (no React Native +renderer). The explicitly stable-signed helper was ready; its noninteractive +doctor reported Accessibility and PostEvent `false`, ScreenCapture `true`, +console connected and unlocked. No privacy or certificate-trust changes +were made. Native service/application identity and backend contracts passed +27 with 15 skipped Windows cases. These do not qualify GUI handoff. + +| Selection and exact command | Result | Cleanup and retained evidence | +| --------------------------- | ------ | ----------------------------- | +| `FURN_NATIVE_DRIVER_TEST=1 yarn workspace @fluentui-react-native/desktop-driver test serviceIdentity.contract applicationIncarnation.contract nativeDriver --runInBand` | Passed 27, failed 0, skipped 15 | `artifacts/desktop/f11/qualification/native-identity.log` | +| `FURN_NATIVE_DRIVER_TEST=1 FURN_NATIVE_HANDOFF_APP_PATH= yarn workspace @fluentui-react-native/desktop-test test nativeRunner.contract --runInBand` | Failed 1; environment-blocked before a session opened: `The target does not support any click input mode.` Worker-crash scenario **not run**. | `artifacts/desktop/f11/native-handoff-5L2MVX/evidence.json`; fixture survived service shutdown, both descriptors removed, claim released; only its spawning test then ended the exact fixture process. | + +Both native commands and helper/fixture builds used the exclusive native +lock. The first handoff attempt used an older fixture binary without +`--ready-file` and failed readiness; it is retained at +`artifacts/desktop/f11/native-handoff-En89el/evidence.json`. The one corrected +fixture retry built and signed the readiness-capable source from +`27b109072`; it reached native-verified private discovery and the permission +gate above. Helper artifact identity: +`e4a23c0ec3cc9ddcf07fde2153bdf260b7b85f65bd0a6851275dd55a6ccf0fd3`. +Redacted logs and helper diagnostics are under +`artifacts/desktop/f11/qualification/`; private descriptors are not retained. + +**Not run:** successful native MCP → WDIO → MCP handoff, native worker-crash +drain, and Windows/Win32 native transport/lifecycle handoff. Existing PR +Windows/Win32 E2E and Storybook smoke jobs do not select this generic +descriptor-path contract; Windows CI is **not sufficient** for F11. +Storybook integration still requires F9, retained launches require explicit +F10 ownership, and cross-MCP-connection resume/replay remain unsupported. From dc7a21fad8568e9cbafd60bae3d1e6681e611a09 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 12 Nov 2026 02:29:20 +0000 Subject: [PATCH 05/23] Format handoff qualification ledger for repository checks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentic/desktop-test/references/current-status.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/agentic/desktop-test/references/current-status.md b/packages/agentic/desktop-test/references/current-status.md index a4edbd75f0..74cb94b0cc 100644 --- a/packages/agentic/desktop-test/references/current-status.md +++ b/packages/agentic/desktop-test/references/current-status.md @@ -1052,10 +1052,10 @@ console connected and unlocked. No privacy or certificate-trust changes were made. Native service/application identity and backend contracts passed 27 with 15 skipped Windows cases. These do not qualify GUI handoff. -| Selection and exact command | Result | Cleanup and retained evidence | -| --------------------------- | ------ | ----------------------------- | -| `FURN_NATIVE_DRIVER_TEST=1 yarn workspace @fluentui-react-native/desktop-driver test serviceIdentity.contract applicationIncarnation.contract nativeDriver --runInBand` | Passed 27, failed 0, skipped 15 | `artifacts/desktop/f11/qualification/native-identity.log` | -| `FURN_NATIVE_DRIVER_TEST=1 FURN_NATIVE_HANDOFF_APP_PATH= yarn workspace @fluentui-react-native/desktop-test test nativeRunner.contract --runInBand` | Failed 1; environment-blocked before a session opened: `The target does not support any click input mode.` Worker-crash scenario **not run**. | `artifacts/desktop/f11/native-handoff-5L2MVX/evidence.json`; fixture survived service shutdown, both descriptors removed, claim released; only its spawning test then ended the exact fixture process. | +| Selection and exact command | Result | Cleanup and retained evidence | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `FURN_NATIVE_DRIVER_TEST=1 yarn workspace @fluentui-react-native/desktop-driver test serviceIdentity.contract applicationIncarnation.contract nativeDriver --runInBand` | Passed 27, failed 0, skipped 15 | `artifacts/desktop/f11/qualification/native-identity.log` | +| `FURN_NATIVE_DRIVER_TEST=1 FURN_NATIVE_HANDOFF_APP_PATH= yarn workspace @fluentui-react-native/desktop-test test nativeRunner.contract --runInBand` | Failed 1; environment-blocked before a session opened: `The target does not support any click input mode.` Worker-crash scenario **not run**. | `artifacts/desktop/f11/native-handoff-5L2MVX/evidence.json`; fixture survived service shutdown, both descriptors removed, claim released; only its spawning test then ended the exact fixture process. | Both native commands and helper/fixture builds used the exclusive native lock. The first handoff attempt used an older fixture binary without From 2be11d35af5eeca82d763a0d3891556511790062 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 12 Nov 2026 02:37:43 +0000 Subject: [PATCH 06/23] Record passing final handoff aggregate validation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../desktop-test/references/current-status.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/agentic/desktop-test/references/current-status.md b/packages/agentic/desktop-test/references/current-status.md index 74cb94b0cc..f81eb2c0a0 100644 --- a/packages/agentic/desktop-test/references/current-status.md +++ b/packages/agentic/desktop-test/references/current-status.md @@ -1045,12 +1045,23 @@ workload holding `interactive.lock`. One focused retry of the two failed CLI contracts under the shared native lock passed both (28 unselected cases skipped). This collision is not native handoff evidence. +The final locked `TF_BUILD=1 yarn lage buildci --concurrency 4` at +`17ee49fd1` passed all 178 tasks, including the complete harness test task. +Project-local ignored `TMPDIR` also required `GIT_CEILING_DIRECTORIES` set +to that directory so temporary instruction-check fixtures did not discover +the enclosing worktree; the existing 24-case instruction checker suite +passed with that isolation. No baseline source was changed for this setup. +The aggregate log is `artifacts/desktop/f11/qualification/buildci.log`. +Task success does not turn the native opt-in skips into qualification. + **Observed, native:** macOS 27.2.1, arm64, plain AppKit fixture (no React Native renderer). The explicitly stable-signed helper was ready; its noninteractive doctor reported Accessibility and PostEvent `false`, ScreenCapture `true`, console connected and unlocked. No privacy or certificate-trust changes were made. Native service/application identity and backend contracts passed -27 with 15 skipped Windows cases. These do not qualify GUI handoff. +27 with 15 skipped Windows cases. AX-dependent handoff is **blocked: +Accessibility not granted to the signed helper**. These identity/backend +checks do not qualify GUI handoff. | Selection and exact command | Result | Cleanup and retained evidence | | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | From 731d246e833720688787b49a6498a4345239db86 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 12 Nov 2026 03:00:18 +0000 Subject: [PATCH 07/23] Harden reusable runner grants, preempt recovery and worker credentials Bound settled grant history without evicting pending cleanup, reserve before issuance, and protect shared descriptor holders. Recover rejected MCP child bindings and isolate WDIO worker credentials before hooks, specs, and child processes run. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/scoped-runner-handoff.md | 2 +- packages/agentic/desktop-driver-mcp/README.md | 2 + .../references/connection.md | 13 ++-- .../desktop-driver-mcp/src/controller.ts | 1 + .../desktop-driver-mcp/src/server.test.ts | 58 +++++++++++++++ packages/agentic/desktop-driver/AGENTS.md | 3 +- .../desktop-driver/references/security.md | 21 ++++-- .../desktop-driver/references/service.md | 3 +- .../src/authority/delegation.test.ts | 33 ++++++++- .../desktop-driver/src/authority/grants.ts | 71 +++++++++++++++---- .../src/server/SessionManager.ts | 32 ++++++--- .../src/server/createDesktopDriverServer.ts | 22 ++++-- .../src/server/delegation.test.ts | 32 +++++++++ .../src/server/runnerHandoff.test.ts | 29 +++++++- packages/agentic/desktop-test/README.md | 2 + .../desktop-test/references/wdio-suites.md | 16 +++++ .../desktop-test/src/wdio/configuration.ts | 36 +++++++--- packages/agentic/desktop-test/src/wdio/run.ts | 1 + .../desktop-test/src/wdio/runner.contract.cjs | 51 ++++++++++--- .../desktop-test/src/wdio/runnerConnection.ts | 8 ++- 20 files changed, 371 insertions(+), 65 deletions(-) diff --git a/.changeset/scoped-runner-handoff.md b/.changeset/scoped-runner-handoff.md index c950f7a0e9..262c8d8f29 100644 --- a/.changeset/scoped-runner-handoff.md +++ b/.changeset/scoped-runner-handoff.md @@ -4,4 +4,4 @@ '@fluentui-react-native/desktop-driver-mcp': patch --- -Add attach-only scoped runner grants, drain-confirmed MCP relinquish and standalone WDIO handoff, with parent-owned worker-crash cleanup and private credential transport. Storybook handoff and retained launched applications remain unsupported. +Add attach-only scoped runner grants, drain-confirmed MCP relinquish and standalone WDIO handoff, with parent-owned worker-crash cleanup and private credential transport. Bound live/draining grant authority separately from settled cleanup history, preserve shared descriptor roots during preemption, and allow preempted MCP controllers to reopen with fresh authority. Deliver worker credentials individually and scrub them before user hooks/specs and their children run; Node bootstrap remains trusted. Storybook handoff and retained launched applications remain unsupported. diff --git a/packages/agentic/desktop-driver-mcp/README.md b/packages/agentic/desktop-driver-mcp/README.md index dd57f8d803..34c58b5f01 100644 --- a/packages/agentic/desktop-driver-mcp/README.md +++ b/packages/agentic/desktop-driver-mcp/README.md @@ -26,6 +26,8 @@ delegation fail closed. See `desktop_session` with `action: "relinquish"` cancels and drain-confirms the controller before an operator's generic attach-only WDIO runner takes over. After runner cleanup, open a fresh session; no old resources are resumed. +Operator preemption also allows a fresh child after authoritative rejection; +it does not require restarting the MCP connection. See [session actions](references/tools.md#desktop_session). ## Use it diff --git a/packages/agentic/desktop-driver-mcp/references/connection.md b/packages/agentic/desktop-driver-mcp/references/connection.md index d6c59def20..f94388627e 100644 --- a/packages/agentic/desktop-driver-mcp/references/connection.md +++ b/packages/agentic/desktop-driver-mcp/references/connection.md @@ -123,6 +123,10 @@ existing private child client, never another connection's child. This retains known-session cleanup responsibility rather than resuming across connections. A replacement service or an authoritative invalid/revoked session invalidates old references; open a new session and observe again. +Authoritative child rejection also discards its cached binding. Once an +operator-preempting runner has drained, the same MCP connection can delegate a +fresh child from the still-live descriptor; it cannot reuse the revoked child +or any old references, cursors, proofs, or artifact links. Nothing automatically replays the failed operation. Operations, acquisition, close, and complete resource reads are serialized per @@ -145,10 +149,11 @@ retry an incomplete cleanup while the process remains alive. There is no resume across different MCP connections and no crash-safe native recovery promise; see [current status](current-status.md). -The authority limits delegation to eight edges and 4096 grants per service -incarnation, including revoked roots/children. Exhaustion is explicit and -requires an operator-controlled drain and service replacement. Cleanup proof -is never silently evicted or reused. +The authority limits delegation to eight edges and 4096 live or not-yet-drained +grants. Successful subtree drains restore capacity without restarting. +Pending and failed cleanup proof is never evicted; terminal retry proof for +the last 4096 fully drained records remains available. Older settled records +expire and their credentials fail closed, without recovering driver authority. ## Embedding diff --git a/packages/agentic/desktop-driver-mcp/src/controller.ts b/packages/agentic/desktop-driver-mcp/src/controller.ts index 5e5e58cb5c..f7fea5e6f2 100644 --- a/packages/agentic/desktop-driver-mcp/src/controller.ts +++ b/packages/agentic/desktop-driver-mcp/src/controller.ts @@ -388,6 +388,7 @@ export class DesktopController { this.#open = undefined; this.#uncertain = undefined; this.#connection = undefined; + this.#binding = undefined; throw new AdapterError( 'service-unavailable', 'The Desktop Driver authority is no longer valid. Reconnect before opening a session.', diff --git a/packages/agentic/desktop-driver-mcp/src/server.test.ts b/packages/agentic/desktop-driver-mcp/src/server.test.ts index a2d0f031c9..2b5745b25a 100644 --- a/packages/agentic/desktop-driver-mcp/src/server.test.ts +++ b/packages/agentic/desktop-driver-mcp/src/server.test.ts @@ -235,6 +235,64 @@ describe('desktop-driver-mcp', () => { }); }); + test('recovers a fresh child after runner preemption while old resources remain invalid', async () => { + await withFixture({}, async ({ client, harness }) => { + harness.target.launchModes = ['attach']; + const request = { action: 'open', launch: false, target: harness.target.id }; + const opened = await call(client, 'desktop_session', request); + expect(opened.isError).toBeUndefined(); + const previous = json(opened).id as string; + const owner = harness.server.sessions.get(previous).owner?.grantId; + const observed = json(await call(client, 'desktop_observe', { observe: { budget: { maxNodes: 1 } } })).result as { next: string }; + const found = json(await call(client, 'desktop_observe', { find: { automationId: 'button-primary' } })).result as { + matches: { ref: string }[]; + sample: { id: string }; + }; + const captured = await call(client, 'desktop_capture'); + const link = captured.content.find((block) => block.type === 'resource_link'); + expect(link).toBeDefined(); + const authority = connections.get(harness)!.authority; + const operator = authority.issue({ + label: 'operator', + targets: [harness.target.id], + scopes: ['driver-control', 'declared-workload', 'operator-recovery'], + }); + const root = createDesktopDriverClient({ grant: operator.token, url: harness.server.url }); + const connection = await root.request<{ token: string }>('POST', '/furn/grants/runner-connection', {}); + const runner = createDesktopDriverClient({ grant: connection.token, url: harness.server.url }); + try { + const reservation = await runner.request<{ token: string }>('POST', '/furn/grants/runner', { + target: harness.target.id, + handoff: 'preempt', + }); + const invalidated = await call(client, 'desktop_session'); + expect(invalidated.isError).toBe(true); + expect(json(invalidated)).toMatchObject({ reason: 'service-unavailable' }); + const worker = await createDesktopDriverClient({ grant: reservation.token, url: harness.server.url }).delegateConnection(); + await worker.newSession({ alwaysMatch: { 'furn:target': harness.target.id, 'furn:launchMode': 'attach' } }); + await runner.revokeConnection(); + const reopened = await call(client, 'desktop_session', request); + expect(reopened.isError).toBeUndefined(); + const next = json(reopened).id as string; + expect(harness.server.sessions.get(next).owner?.grantId).not.toBe(owner); + expect(() => harness.server.sessions.get(previous)).toThrow(); + const checked = await call(client, 'desktop_check', { ref: found.matches[0].ref, expect: { enabled: true } }); + expect(checked.isError).toBe(true); + expect(json(checked)).toMatchObject({ reason: expect.stringMatching(/^ref-/) }); + const page = await call(client, 'desktop_observe', { read: { cursor: observed.next } }); + expect(page.isError).toBe(true); + expect(json(page)).toMatchObject({ reason: 'observation-cursor-expired' }); + const acted = await call(client, 'desktop_act', { + steps: [{ act: { action: 'activate', ref: found.matches[0].ref, sample: found.sample.id } }], + }); + expect(json(acted)).toMatchObject({ result: { status: 'stopped', steps: [{ error: { reason: expect.any(String) } }] } }); + if (link?.type === 'resource_link') await expect(client.readResource({ uri: link.uri })).rejects.toThrow(); + } finally { + await runner.revokeConnection(); + } + }); + }); + test('isolates controllers sharing a descriptor principal instead of sharing sessions', async () => { const harness = await createDesktopDriverTestHarness({ features }); const first = new DesktopController(async () => testConnection(harness)); diff --git a/packages/agentic/desktop-driver/AGENTS.md b/packages/agentic/desktop-driver/AGENTS.md index 9d3722004d..c99fe3f92a 100644 --- a/packages/agentic/desktop-driver/AGENTS.md +++ b/packages/agentic/desktop-driver/AGENTS.md @@ -26,7 +26,8 @@ editing. separate. Child grants may only attenuate targets/scopes. Revoke descendant authority synchronously and await acquisition/session drains; revoked-child proof authorizes only retrying that child's terminal cleanup, never driver - access. Never evict cleanup proof to reuse the finite lifetime grant budget. + access. Bound live/draining authority and settled history separately; never + evict pending or failed subtree cleanup proof to admit new connections. - Runner handoff fences admission before cancellation/drain and confirms deletion before granting an attach-only reservation. Descendants inherit the controller restriction. Do not release that reservation on notification diff --git a/packages/agentic/desktop-driver/references/security.md b/packages/agentic/desktop-driver/references/security.md index 1ce01ce094..7c30a105ab 100644 --- a/packages/agentic/desktop-driver/references/security.md +++ b/packages/agentic/desktop-driver/references/security.md @@ -81,10 +81,12 @@ in its command queue authenticates again immediately before dispatch. `driver-control`, with the parent's target set and label. The typed client's `delegateConnection()` keeps the returned credential private. Neither a parent nor a sibling can address the child's sessions. -- Delegation is bounded to eight edges and 4096 total grants per service - incarnation, including roots and revoked grants. Exhaustion fails explicitly; - cleanup proof is never evicted or recycled to admit new connections. Operators - must drain and replace the service to reset this finite lifetime budget. +- Delegation is bounded to eight edges and 4096 live or not-yet-drained grants, + including roots. Confirmed subtree drain restores capacity, so repeated + connections and runner handoffs do not exhaust a service-lifetime budget. + Pending or failed drain proof is never evicted. The last 4096 fully drained + records retain terminal-only retry proof; older settled records expire and + their credentials fail closed, never regain driver authority. - A session belongs to the grant that created it. Every other grant gets the answer for an id that does not exist, and that check runs before the command queues, so a grant can neither probe for another grant's session nor wait in, @@ -112,7 +114,9 @@ in its command queue authenticates again immediately before dispatch. exact terminal route** so a lost response or failed drain can be retried. It cannot authorize status, session work, delegation, or another principal's cleanup. Unauthorized is not cleanup evidence. Tombstones retain no plaintext - secret and live until the service incarnation ends. + secret; only completely drained subtrees are eligible for bounded-history + expiry. A retired authenticated request still checks live authority before + admission, even after its settled record expires. - Ordinary session HTTP `DELETE` is queued behind current work before drain begins. Neither revocation notification nor a ten-minute idle setting proves settlement; handoff must await session deletion or the explicit grant drain. @@ -123,10 +127,13 @@ in its command queue authenticates again immediately before dispatch. from such an isolated workload connection. The target must be generic and advertise attach-only launch modes. The default is bounded `target-busy`; `preempt` also requires `operator-recovery` and cannot revoke the acquiring - connection or one of its ancestors. + connection or one of its ancestors. Shared descriptor roots and connections + retaining workload/recovery authority return `target-busy` with an explicit + relinquish requirement rather than being permanently revoked. - Runner acquisition fences admission before draining an old controller. Only after cancellation, native settlement, opening-lease cleanup, and deletion - are confirmed does it return a driver-only, target-scoped attach-only child. + are confirmed does it issue and return a driver-only, target-scoped attach-only + child. Busy or failed handoff attempts issue no runner grant. Ordinary descendants inherit that restriction and share the reservation. The reservation excludes MCP even between workers and is released only after terminal subtree drain. Failed drains retain the fence and retryable proof. diff --git a/packages/agentic/desktop-driver/references/service.md b/packages/agentic/desktop-driver/references/service.md index 9dfaf4ff4a..1cf99d2eff 100644 --- a/packages/agentic/desktop-driver/references/service.md +++ b/packages/agentic/desktop-driver/references/service.md @@ -338,7 +338,8 @@ const client = createDesktopDriverClient({ grant: agent.token, url: service.url `client.delegateConnection()` returns a separate driver-only client; `child.revokeConnection()` awaits self-cleanup and remains retryable with terminal-only proof after revocation. See [security](security.md) for the - eight-edge depth and 4096-grant service-lifetime limits. + eight-edge depth, 4096 live/draining grant limit, and bounded settled + cleanup-proof history. Successful drains restore capacity without restarting. - A grant's session closes after `idleSessionTimeoutMs` without a request for it, ten minutes by default. Every request for the session renews it, and a running request is never interrupted by expiry. Pass `0` to disable expiry. diff --git a/packages/agentic/desktop-driver/src/authority/delegation.test.ts b/packages/agentic/desktop-driver/src/authority/delegation.test.ts index c8cec8cb83..db92e3fe30 100644 --- a/packages/agentic/desktop-driver/src/authority/delegation.test.ts +++ b/packages/agentic/desktop-driver/src/authority/delegation.test.ts @@ -144,7 +144,7 @@ describe('attenuated grants', () => { expect(authority.issue({ label: 'default', targets: '*' }).scopes).toEqual(['driver-control']); }); - test('bounds depth and service-lifetime issuance without evicting cleanup proof', async () => { + test('bounds delegation depth and live grants without evicting recent cleanup proof', async () => { const authority = new DesktopAuthority(); const parent = authority.issue({ label: 'agent', targets: '*' }); let current = parent; @@ -153,9 +153,38 @@ describe('attenuated grants', () => { } expect(() => authority.delegate(current.id, { label: 'too-deep', targets: '*' })).toThrow(); await authority.revokeAndDrain(current.id); - for (let index = 9; index < 4096; index += 1) authority.issue({ label: 'bounded', targets: '*' }); + for (let index = 8; index < 4096; index += 1) authority.issue({ label: 'bounded', targets: '*' }); expect(() => authority.issue({ label: 'exhausted', targets: '*' })).toThrow('limit'); expect(authority.authenticateRevocation(`Bearer ${current.token}`)?.id).toBe(current.id); expect(authority.authenticate(`Bearer ${current.token}`)).toBeUndefined(); }); + + test('reuses drained capacity beyond 4096 delegations while preserving undrained terminal proof', async () => { + const authority = new DesktopAuthority(); + const parent = authority.issue({ label: 'agent', targets: '*' }); + const blocked = authority.delegate(parent.id, { label: 'blocked', targets: '*' }); + const descendant = authority.delegate(blocked.id, { label: 'pending', targets: '*' }); + let finish!: () => void; + const pending = new Promise((resolve) => (finish = resolve)); + authority.onRevoke(({ id }) => (id === descendant.id ? pending : undefined)); + const barrier = authority.revokeAndDrain(blocked.id); + const first = authority.delegate(parent.id, { label: 'cycle', targets: '*' }); + await authority.revokeAndDrain(first.id); + let last = first; + for (let index = 0; index < 4100; index += 1) { + last = authority.delegate(parent.id, { label: 'cycle', targets: '*' }); + await authority.revokeAndDrain(last.id); + } + expect(authority.authenticate(`Bearer ${parent.token}`)?.id).toBe(parent.id); + expect(authority.authenticate(`Bearer ${first.token}`)).toBeUndefined(); + expect(authority.authenticateRevocation(`Bearer ${first.token}`)).toBeUndefined(); + expect(authority.authenticateRevocation(`Bearer ${last.token}`)?.id).toBe(last.id); + expect(authority.authenticateRevocation(`Bearer ${blocked.token}`)?.id).toBe(blocked.id); + expect(authority.authenticateRevocation(`Bearer ${descendant.token}`)?.id).toBe(descendant.id); + expect(authority.authenticate(`Bearer ${blocked.token}`)).toBeUndefined(); + finish(); + await barrier; + await expect(authority.revokeAndDrain(last.id)).resolves.toBeUndefined(); + expect(authority.delegate(parent.id, { label: 'fresh', targets: '*' }).id).toBeDefined(); + }); }); diff --git a/packages/agentic/desktop-driver/src/authority/grants.ts b/packages/agentic/desktop-driver/src/authority/grants.ts index a25e1ef38f..cb56dfd58c 100644 --- a/packages/agentic/desktop-driver/src/authority/grants.ts +++ b/packages/agentic/desktop-driver/src/authority/grants.ts @@ -35,8 +35,9 @@ export type DesktopGrant = { export type IssuedDesktopGrant = DesktopGrant & { readonly token: string }; type StoredGrant = { digest: Buffer; grant: DesktopGrant; depth: number }; -type Drain = { pending: Promise; failed: boolean; failureReported: boolean }; +type Drain = { pending: Promise; failed: boolean; failureReported: boolean; succeeded: boolean }; const maxGrants = 4096; +const maxDrainedGrants = 4096; const maxDepth = 8; /** @@ -55,7 +56,8 @@ export class DesktopAuthority { private readonly revocationListeners = new Set<(grant: DesktopGrant) => unknown>(); private readonly drains = new Map(); private readonly revoked = new Map(); - private issuedCount = 0; + private readonly children = new Map>(); + private readonly drained = new Set(); // Verifying a token for an unknown grant compares against this so the two failures take the same work. private readonly unknownDigest = digestOf(randomBytes(32).toString('base64url')); @@ -63,6 +65,16 @@ export class DesktopAuthority { return this.create(request, undefined); } + isActive(grantId: string): boolean { + return this.grants.has(grantId); + } + + /** Shared descriptor roots and workload connections must relinquish instead of being permanently preempted. */ + canPreempt(grantId: string): boolean { + const grant = (this.grants.get(grantId) ?? this.revoked.get(grantId))?.grant; + return grant?.parentId !== undefined && grant.scopes.length === 1 && grant.scopes[0] === 'driver-control'; + } + /** Trusted callers may attenuate targets and permissions, never expand them. */ delegate(parentId: string, request: GrantRequest): IssuedDesktopGrant { const parent = this.grants.get(parentId); @@ -105,7 +117,9 @@ export class DesktopAuthority { } private create(request: GrantRequest, parent: StoredGrant | undefined, runner = false): IssuedDesktopGrant { - if (this.issuedCount >= maxGrants) throw new RangeError('The service grant limit has been reached.'); + if (this.grants.size + this.revoked.size - this.drained.size >= maxGrants) { + throw new RangeError('The service live or draining grant limit has been reached.'); + } const label = request.label; if (typeof label !== 'string' || !labelPattern.test(label)) { throw new TypeError('A grant label must be 1-32 characters of letters, digits, ".", "_", ":" or "-".'); @@ -124,7 +138,11 @@ export class DesktopAuthority { ...(restriction ? { runner: restriction } : {}), }); this.grants.set(id, { digest: digestOf(secret), grant, depth: parent ? parent.depth + 1 : 0 }); - this.issuedCount += 1; + if (parent) { + const children = this.children.get(parent.grant.id) ?? new Set(); + children.add(id); + this.children.set(parent.grant.id, children); + } return Object.freeze({ ...grant, token: `${tokenScheme}.${id}.${secret}` }); } @@ -173,19 +191,18 @@ export class DesktopAuthority { } private subtree(root: StoredGrant): StoredGrant[] { - const records = [...this.grants.values(), ...this.revoked.values()]; - return records.filter((candidate) => { - let current: StoredGrant | undefined = candidate; - while (current) { - if (current === root) return true; - const parentId = current.grant.parentId ?? ''; - current = this.grants.get(parentId) ?? this.revoked.get(parentId); + const records = [root]; + for (const { grant } of records) { + for (const id of this.children.get(grant.id) ?? []) { + const child = this.grants.get(id) ?? this.revoked.get(id); + if (child) records.push(child); } - return false; - }); + } + return records; } - private startDrain({ grant }: StoredGrant): Drain { + private startDrain(stored: StoredGrant): Drain { + const { grant } = stored; let resolve!: () => void; let reject!: (reason: unknown) => void; const drain: Drain = { @@ -195,6 +212,7 @@ export class DesktopAuthority { }), failed: false, failureReported: false, + succeeded: false, }; this.drains.set(grant.id, drain); // Install the barrier before calling listeners, which may themselves revoke another branch. @@ -217,12 +235,37 @@ export class DesktopAuthority { ), ); } else { + drain.succeeded = true; + this.recordDrain(stored); resolve(); } }); return drain; } + private recordDrain(stored: StoredGrant): void { + let current: StoredGrant | undefined = stored; + while (current && this.revoked.has(current.grant.id)) { + const branch = this.subtree(current); + if (!branch.every(({ grant }) => this.drains.get(grant.id)?.succeeded)) break; + for (const { grant } of branch) this.drained.add(grant.id); + current = this.revoked.get(current.grant.parentId ?? ''); + } + while (this.drained.size > maxDrainedGrants) { + const id = this.drained.values().next().value!; + const parentId = this.revoked.get(id)?.grant.parentId; + this.drained.delete(id); + this.revoked.delete(id); + this.drains.delete(id); + this.children.delete(id); + if (parentId) { + const siblings = this.children.get(parentId); + siblings?.delete(id); + if (siblings?.size === 0) this.children.delete(parentId); + } + } + } + /** Joins the original drain even when synchronous revocation already invalidated the grant. */ async revokeAndDrain(grantId: string): Promise { this.revoke(grantId); diff --git a/packages/agentic/desktop-driver/src/server/SessionManager.ts b/packages/agentic/desktop-driver/src/server/SessionManager.ts index 0936a5af00..141de0c546 100644 --- a/packages/agentic/desktop-driver/src/server/SessionManager.ts +++ b/packages/agentic/desktop-driver/src/server/SessionManager.ts @@ -55,6 +55,8 @@ type TargetHolder = { }; export type SessionManagerOptions = { + /** Checks the live authority at every admission, including requests authenticated before an earlier drain. */ + isOwnerActive?: (grantId: string) => boolean; /** * Closes a grant's session after this many milliseconds without a request for it. The clock runs only while none of the * session's requests is queued or running, so a long command never expires its own session. Sessions without an owner @@ -152,6 +154,7 @@ export class SessionManager { private readonly probeApplication: ProbeApplication | undefined; private readonly probeDeadlineMs: number; private readonly redactSessionIds: boolean; + private readonly isOwnerActive: SessionManagerOptions['isOwnerActive']; constructor(options: SessionManagerOptions = {}) { this.idleTimeoutMs = options.idleTimeoutMs ?? 0; @@ -159,6 +162,7 @@ export class SessionManager { this.probeApplication = options.probeApplication; this.probeDeadlineMs = options.probeDeadlineMs ?? defaultStorageDeadlineMs; this.redactSessionIds = options.redactSessionIds === true; + this.isOwnerActive = options.isOwnerActive; } /** @@ -274,12 +278,22 @@ export class SessionManager { ); } + private isRevokedOwner(grantId: string): boolean { + return this.revokedOwners.has(grantId) || this.isOwnerActive?.(grantId) === false; + } + /** * Reserves admission before draining the previous owner. Notification of revocation is not proof: the supplied drain * must settle and the target must have no session, opening lease, or mutation before this controller can create. */ - reserveRunner(targetId: string, owner: SessionOwner, preempt?: (grantId: string) => Promise, signal?: AbortSignal): Promise { - if (this.closing || this.revokedOwners.has(owner.grantId)) { + reserveRunner( + targetId: string, + owner: SessionOwner, + acquireOwner: () => SessionOwner, + preempt?: (grantId: string) => Promise, + signal?: AbortSignal, + ): Promise { + if (this.closing || this.isRevokedOwner(owner.grantId)) { return Promise.reject(new WebDriverError('session not created', 'Runner authority is unavailable.')); } if (this.handoffs.has(targetId)) return Promise.reject(this.createBusyError(targetId)); @@ -303,7 +317,7 @@ export class SessionManager { try { if (previous) await preempt!(previous); controller.signal.throwIfAborted(); - if (this.closing || this.revokedOwners.has(owner.grantId)) { + if (this.closing || this.isRevokedOwner(owner.grantId)) { throw new WebDriverError('session not created', 'Runner authority was revoked while draining.'); } if ( @@ -316,9 +330,10 @@ export class SessionManager { ) { throw this.createBusyError(targetId); } + const runner = acquireOwner(); this.runnerReservations.set(targetId, { - grantId: owner.grantId, - label: owner.label, + grantId: runner.grantId, + label: runner.label, lifecycle: 'active', since: new Date().toISOString(), }); @@ -344,7 +359,7 @@ export class SessionManager { } // Checked in the same tick that the create registers as in flight, so a create is either rejected here or awaited // by deleteOwnedBy. - if (owner && (this.revokedOwners.has(owner.grantId) || (owner.controllerId && this.revokedOwners.has(owner.controllerId)))) { + if (owner && (this.isRevokedOwner(owner.grantId) || (owner.controllerId && this.isRevokedOwner(owner.controllerId)))) { return Promise.reject(new WebDriverError('session not created', 'The grant for this request was revoked.')); } const controller = new AbortController(); @@ -406,7 +421,7 @@ export class SessionManager { if (this.closing) { throw new WebDriverError('session not created', 'Desktop Driver is shutting down.'); } - if (owner && (this.revokedOwners.has(owner.grantId) || (owner.controllerId && this.revokedOwners.has(owner.controllerId)))) { + if (owner && (this.isRevokedOwner(owner.grantId) || (owner.controllerId && this.isRevokedOwner(owner.controllerId)))) { throw new WebDriverError('session not created', 'The grant for this request was revoked.'); } const runner = this.runnerReservations.get(target.id); @@ -452,7 +467,7 @@ export class SessionManager { if (this.closing) { throw new WebDriverError('session not created', 'Desktop Driver shut down while the target was starting.'); } - if (owner && (this.revokedOwners.has(owner.grantId) || (owner.controllerId && this.revokedOwners.has(owner.controllerId)))) { + if (owner && (this.isRevokedOwner(owner.grantId) || (owner.controllerId && this.isRevokedOwner(owner.controllerId)))) { throw new WebDriverError('session not created', 'The grant for this request was revoked.'); } const session: DesktopSession = { @@ -925,6 +940,7 @@ export class SessionManager { for (const [targetId, reservation] of this.runnerReservations) { if (reservation.grantId === grantId) this.runnerReservations.delete(targetId); } + if (this.isOwnerActive?.(grantId) === false) this.revokedOwners.delete(grantId); } private async deleteWhere(predicate: (session: DesktopSession) => boolean, summary: string): Promise { diff --git a/packages/agentic/desktop-driver/src/server/createDesktopDriverServer.ts b/packages/agentic/desktop-driver/src/server/createDesktopDriverServer.ts index 79e7c3f0ba..e4e7632c4a 100644 --- a/packages/agentic/desktop-driver/src/server/createDesktopDriverServer.ts +++ b/packages/agentic/desktop-driver/src/server/createDesktopDriverServer.ts @@ -131,6 +131,7 @@ export async function createDesktopDriverServer(options: DesktopDriverServerOpti options.authority?.assertTargetsRegistered(new Set(targets.list().map(({ id }) => id))); const sessions = new SessionManager({ idleTimeoutMs: resolveIdleSessionTimeout(options), + ...(options.authority ? { isOwnerActive: (id: string) => options.authority!.isActive(id) } : {}), ...(options.recovery ? { journal: new SettlementJournal(options.recovery.storage) } : {}), ...(options.recovery?.probeApplication ? { probeApplication: options.recovery.probeApplication } : {}), redactSessionIds: options.authority !== undefined, @@ -421,14 +422,25 @@ async function route(context: RouteContext, sessions: SessionManager, targets: T if (target.storyOrchestrator || target.launchModes?.length !== 1 || target.launchModes[0] !== 'attach') { throw new WebDriverError('unsupported operation', 'Runner handoff requires a generic attach-only target.'); } - const child = context.authority.delegateRunner(context.grant.id, target.id); + let child: ReturnType | undefined; try { await sessions.reserveRunner( target.id, - { grantId: child.id, controllerId: child.id, label: child.label }, + { grantId: context.grant.id, label: context.grant.label }, + () => { + child = context.authority!.delegateRunner(context.grant!.id, target.id); + return { grantId: child.id, controllerId: child.id, label: child.label }; + }, request.handoff === 'preempt' ? (id) => { - if (context.authority!.isDescendantOf(child.id, id)) { + if (!context.authority!.canPreempt(id)) { + throw new WebDriverError( + 'session not created', + 'A shared descriptor or workload connection holds this target. Relinquish it before requesting runner authority.', + { reason: 'target-busy' }, + ); + } + if (context.authority!.isDescendantOf(context.grant!.id, id)) { throw new WebDriverError( 'session not created', 'Relinquish the acquiring connection before requesting runner authority.', @@ -443,9 +455,9 @@ async function route(context: RouteContext, sessions: SessionManager, targets: T context.signal, ); context.signal.throwIfAborted(); - return { token: child.token }; + return { token: child!.token }; } catch (error) { - await context.authority.revokeAndDrain(child.id); + if (child) await context.authority.revokeAndDrain(child.id); throw error; } } diff --git a/packages/agentic/desktop-driver/src/server/delegation.test.ts b/packages/agentic/desktop-driver/src/server/delegation.test.ts index 4a1127b58e..3b66308dc1 100644 --- a/packages/agentic/desktop-driver/src/server/delegation.test.ts +++ b/packages/agentic/desktop-driver/src/server/delegation.test.ts @@ -158,4 +158,36 @@ describe('connection grant routes', () => { await harness.close(); } }); + + test('rejects a request authenticated before its owner drained even when target probing finishes later', async () => { + const authority = new DesktopAuthority(); + const parent = authority.issue({ label: 'agent', targets: '*' }); + const harness = await createDesktopDriverTestHarness({}, undefined, { authority }); + const client = createDesktopDriverClient({ grant: parent.token, url: harness.server.url }); + let entered!: () => void; + let finish!: () => void; + const started = new Promise((resolve) => (entered = resolve)); + const pending = new Promise((resolve) => (finish = resolve)); + const probe = harness.host.probe.bind(harness.host); + jest.spyOn(harness.host, 'probe').mockImplementationOnce(async (signal) => { + entered(); + await pending; + return probe(signal); + }); + const launch = jest.spyOn(harness.host, 'launch'); + try { + const child = await client.delegateConnection(); + const opening = expect(child.newSession({ alwaysMatch: { 'furn:target': harness.target.id } })).rejects.toMatchObject({ + code: 'session not created', + }); + await started; + await child.revokeConnection(); + finish(); + await opening; + expect(launch).not.toHaveBeenCalled(); + } finally { + finish(); + await harness.close(); + } + }); }); diff --git a/packages/agentic/desktop-driver/src/server/runnerHandoff.test.ts b/packages/agentic/desktop-driver/src/server/runnerHandoff.test.ts index b4403eaeba..5fac892e94 100644 --- a/packages/agentic/desktop-driver/src/server/runnerHandoff.test.ts +++ b/packages/agentic/desktop-driver/src/server/runnerHandoff.test.ts @@ -16,6 +16,7 @@ async function fixture( authority: DesktopAuthority; host: FakeDesktopHost; agent: DesktopDriverClient; + descriptor: DesktopDriverClient; newAgent(): Promise; runner: DesktopDriverClient; operator: DesktopDriverClient; @@ -40,6 +41,7 @@ async function fixture( authority, host, agent: await client(agent.token).delegateConnection(), + descriptor: client(agent.token), newAgent: () => client(agent.token).delegateConnection(), runner: await runnerConnection(client(runner.token), server.url), operator: await runnerConnection(client(operator.token), server.url), @@ -76,17 +78,38 @@ describe('attach-only runner handoff', () => { }); test('defaults to bounded busy and never gives the MCP child workload or preemption authority', async () => { - await fixture(async ({ agent, capabilities, operator, runner, url }) => { + await fixture(async ({ agent, authority, capabilities, operator, runner, url }) => { const session = await agent.newSession(capabilities); + const delegate = jest.spyOn(authority, 'delegateRunner'); await expect(acquire(runner, url)).rejects.toMatchObject({ data: { reason: 'target-busy' } }); await expect(acquire(runner, url, 'preempt')).rejects.toMatchObject({ code: 'unauthorized' }); await expect(acquire(agent, url, 'preempt')).rejects.toMatchObject({ code: 'unauthorized' }); await expect(runnerConnection(agent, url)).rejects.toMatchObject({ code: 'unauthorized' }); + expect(delegate).not.toHaveBeenCalled(); await expect(acquire(operator, url, 'preempt')).resolves.toBeDefined(); await expect(session.getWindowHandles()).rejects.toMatchObject({ code: 'unauthorized' }); }); }); + test.each(['descriptor', 'workload'] as const)( + 'refuses preemption of a shared %s grant without revoking its children', + async (holder) => { + await fixture(async ({ authority, capabilities, descriptor, operator, runner, url }) => { + const owner = holder === 'descriptor' ? descriptor : runner; + const sibling = await descriptor.delegateConnection(); + const session = await owner.newSession(capabilities); + const delegate = jest.spyOn(authority, 'delegateRunner'); + await expect(acquire(operator, url, 'preempt')).rejects.toMatchObject({ + data: { reason: 'target-busy' }, + message: expect.stringContaining('shared descriptor or workload connection'), + }); + expect(delegate).not.toHaveBeenCalled(); + await expect(session.getWindowHandles()).resolves.not.toHaveLength(0); + await expect(sibling.status()).resolves.toMatchObject({ ready: true }); + }); + }, + ); + test('refuses preemption of an ancestor rather than joining its own pending handoff drain', async () => { await fixture(async ({ capabilities, operator, url }) => { const session = await operator.newSession(capabilities); @@ -188,11 +211,13 @@ describe('attach-only runner handoff', () => { }); test('failed preemption does not authorize a new controller and its terminal cleanup is retryable', async () => { - await fixture(async ({ agent, capabilities, host, operator, runner, url }) => { + await fixture(async ({ agent, authority, capabilities, host, operator, runner, url }) => { await agent.newSession(capabilities); + const delegate = jest.spyOn(authority, 'delegateRunner'); jest.spyOn(host, 'releaseActions').mockRejectedValueOnce(attachFailureEffect(new Error('injected release failure'), 'none')); await expect(acquire(operator, url, 'preempt')).rejects.toBeDefined(); await expect(acquire(runner, url)).rejects.toMatchObject({ data: { reason: 'target-busy' } }); + expect(delegate).not.toHaveBeenCalled(); await agent.revokeConnection(); await expect(acquire(runner, url)).resolves.toBeDefined(); }); diff --git a/packages/agentic/desktop-test/README.md b/packages/agentic/desktop-test/README.md index c7f5c00792..b6852feb33 100644 --- a/packages/agentic/desktop-test/README.md +++ b/packages/agentic/desktop-test/README.md @@ -63,6 +63,8 @@ when working on the package. framework assertions. - Generic externally owned apps support drain-confirmed MCP/WDIO handoff via `test --attach-agent`; see [runner ownership](references/wdio-suites.md#reuse-an-attached-agent-service). + Per-worker credentials are scrubbed before user hooks/specs; Node bootstrap + and preloads remain trusted during the initial fork-environment window. Storybook handoff and retained session launches remain unsupported. - The generic harness supervises services, workers, and the driver; session-owned app launch and the Storybook lifecycle takeover are transitional (see diff --git a/packages/agentic/desktop-test/references/wdio-suites.md b/packages/agentic/desktop-test/references/wdio-suites.md index 09687be0af..5e0335fb8a 100644 --- a/packages/agentic/desktop-test/references/wdio-suites.md +++ b/packages/agentic/desktop-test/references/wdio-suites.md @@ -46,6 +46,11 @@ the service with `--allow-runner-preempt`, then request `test --attach-agent commands, cancels and awaits in-flight native work, and confirms session deletion before a target-scoped, attach-only runner grant is returned. A failed drain retains the busy fence; revocation notification is not proof. +Preemption is limited to delegated driver-only controllers. A descriptor root +or a connection retaining workload/recovery authority returns `target-busy` +and must explicitly relinquish, so unrelated descriptor users are not revoked. +After a preempted MCP child is authoritatively rejected and the runner drains, +the same MCP connection can open with a fresh child. The parent runner keeps the reservation between WDIO workers, hands each worker a distinct driver-only child via private process transport, and awaits @@ -56,6 +61,17 @@ worker or acquisition response was lost. Credentials travel only in private descriptors, environment, and request closures; never put them in hooks, capabilities, settings, generated configuration, command arguments, or logs. +The launcher captures and removes broker credentials before importing user +hooks. Each worker receives only its own child token through WDIO's private +`runnerEnv`, never through the launcher's `process.env`; the worker captures +and removes it before user hooks or specs load. Hooks, specs, and children +they spawn therefore do not inherit broker or worker credential variables. +The per-worker `runnerEnv` entry is cleared at worker end. Node bootstrap and +preloads run before configuration capture and remain trusted: the worker token +exists in the fork environment during that bootstrap window. This transport +does not isolate mutually hostile code running as the same operating-system +account. + Each handoff opens a new session: old element references, observation cursors, uniqueness proofs, and session-qualified artifact links expire. There is no cross-MCP-connection resume or automatic replay. The externally owned app is diff --git a/packages/agentic/desktop-test/src/wdio/configuration.ts b/packages/agentic/desktop-test/src/wdio/configuration.ts index 3c9c9d0ce4..eafbc2072e 100644 --- a/packages/agentic/desktop-test/src/wdio/configuration.ts +++ b/packages/agentic/desktop-test/src/wdio/configuration.ts @@ -9,7 +9,12 @@ import { desktopTestSelectionFromEnvironment } from '../jasmine/selection.js'; import { currentDesktopJasmineEnvironment, selectDesktopSpecs } from '../jasmine/specSelection.js'; import { connectReportProducer } from '../reporting/transport.js'; import { DesktopReportedError, serializeReportError } from '../reporting/protocol.js'; -import { requestDesktopRunnerWorker, runnerBrokerUrlEnvironment, runnerWorkerTokenEnvironment } from './runnerConnection.js'; +import { + requestDesktopRunnerWorker, + runnerBrokerTokenEnvironment, + runnerBrokerUrlEnvironment, + runnerWorkerTokenEnvironment, +} from './runnerConnection.js'; export type DesktopWdioSettings = { url: string; @@ -27,6 +32,7 @@ export type DesktopWdioSettings = { timeoutMs: number; launchTimeoutMs: number; readiness?: DesktopReadiness; + scopedRunner?: boolean; }; type Hook = (...args: unknown[]) => unknown; @@ -50,6 +56,10 @@ const hookNames = [ ] as const; export async function createDesktopWdioConfiguration(settings: DesktopWdioSettings) { + const broker = { url: process.env[runnerBrokerUrlEnvironment], token: process.env[runnerBrokerTokenEnvironment] }; + const grant = process.env[runnerWorkerTokenEnvironment]; + for (const name of [runnerBrokerUrlEnvironment, runnerBrokerTokenEnvironment, runnerWorkerTokenEnvironment]) delete process.env[name]; + let runnerEnvironment: Record | undefined; let hooks: Partial> = {}; if (settings.hooks) { const loaded: unknown = await import(pathToFileURL(settings.hooks).href); @@ -65,11 +75,14 @@ export async function createDesktopWdioConfiguration(settings: DesktopWdioSettin const readiness = settings.readiness; // The launcher read the selection too, so a malformed one fails the run before any worker starts. const selection = desktopTestSelectionFromEnvironment(); - const broker = process.env[runnerBrokerUrlEnvironment] !== undefined; - const grant = process.env[runnerWorkerTokenEnvironment]; const configuration = { ...hooks, runner: 'local', + runnerEnv: { + [runnerBrokerUrlEnvironment]: undefined, + [runnerBrokerTokenEnvironment]: undefined, + [runnerWorkerTokenEnvironment]: undefined, + }, framework: settings.framework, rootDir: settings.rootDir, specs: settings.specs, @@ -107,18 +120,25 @@ export async function createDesktopWdioConfiguration(settings: DesktopWdioSettin 'furn:inputMode': selection?.lane ?? 'compatibility', }, ], + onPrepare: async (config: { runnerEnv: Record }, ...args: unknown[]) => { + runnerEnvironment = config.runnerEnv; + await hooks.onPrepare?.(config, ...args); + }, onWorkerStart: async (id: string, ...args: unknown[]) => { - delete process.env[runnerWorkerTokenEnvironment]; - if (broker) process.env[runnerWorkerTokenEnvironment] = await requestDesktopRunnerWorker('POST', id); + if (runnerEnvironment) delete runnerEnvironment[runnerWorkerTokenEnvironment]; await hooks.onWorkerStart?.(id, ...args); + if (settings.scopedRunner) { + if (!runnerEnvironment) throw new Error('The WDIO launcher worker environment is unavailable.'); + runnerEnvironment[runnerWorkerTokenEnvironment] = await requestDesktopRunnerWorker('POST', id, broker); + } }, onWorkerEnd: async (id: string, ...args: unknown[]) => { - delete process.env[runnerWorkerTokenEnvironment]; - if (broker) await requestDesktopRunnerWorker('DELETE', id); + if (runnerEnvironment) delete runnerEnvironment[runnerWorkerTokenEnvironment]; + if (settings.scopedRunner) await requestDesktopRunnerWorker('DELETE', id, broker); await hooks.onWorkerEnd?.(id, ...args); }, beforeSession: async (...args: unknown[]) => { - if (broker && !grant) throw new Error('The WDIO worker has no scoped Desktop Driver authority.'); + if (settings.scopedRunner && !grant) throw new Error('The WDIO worker has no scoped Desktop Driver authority.'); process.env.E2ETEST_PLATFORM = settings.endpoint; await hooks.beforeSession?.(...args); }, diff --git a/packages/agentic/desktop-test/src/wdio/run.ts b/packages/agentic/desktop-test/src/wdio/run.ts index 3f760d151a..5ee4152cc6 100644 --- a/packages/agentic/desktop-test/src/wdio/run.ts +++ b/packages/agentic/desktop-test/src/wdio/run.ts @@ -83,6 +83,7 @@ export async function runDesktopWdioSuite( timeoutMs: suite.timeoutMs ?? 60_000, launchTimeoutMs: supervisor.target.launchTimeoutMs ?? 180_000, readiness: suite.readiness ?? supervisor.target.readiness, + scopedRunner: attached !== undefined, }; const settingsPath = supervisor.reporter.artifactPath('wdio.settings.json'); fs.writeFileSync(settingsPath, JSON.stringify({ configurationModule: new URL('./configuration.js', import.meta.url).href, settings }), { diff --git a/packages/agentic/desktop-test/src/wdio/runner.contract.cjs b/packages/agentic/desktop-test/src/wdio/runner.contract.cjs index 96b7b3209b..b83b1c48f8 100644 --- a/packages/agentic/desktop-test/src/wdio/runner.contract.cjs +++ b/packages/agentic/desktop-test/src/wdio/runner.contract.cjs @@ -1,7 +1,6 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const os = require('node:os'); const { pathToFileURL } = require('node:url'); async function main() { @@ -15,7 +14,9 @@ async function main() { const { resolveDesktopScopeDomains } = await import('@fluentui-react-native/desktop-driver/authoring'); const { FakeDesktopHost } = await import('@fluentui-react-native/desktop-driver/testing'); const { createDesktopDriverClient } = await import('@fluentui-react-native/desktop-driver/client'); - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'runner-contract-')); + const artifacts = path.join(packageRoot, 'artifacts', 'runner-contracts'); + fs.mkdirSync(artifacts, { recursive: true }); + const root = fs.mkdtempSync(path.join(artifacts, 'runner-contract-')); let output = ''; const sink = { write: (chunk) => ((output += chunk.toString()), true) }; const reporting = (kind) => new DesktopRunReporter({ artifactsRoot: root, kind, output: sink, errorOutput: sink, summary: false }); @@ -75,25 +76,57 @@ async function main() { }, }); const spec = path.join(root, 'spec.mjs'); + fs.writeFileSync( + path.join(root, 'credential-child.cjs'), + `const assert = require('node:assert/strict'); +for (const name of ['DESKTOP_WDIO_BROKER_URL', 'DESKTOP_WDIO_BROKER_TOKEN', 'DESKTOP_WDIO_WORKER_TOKEN']) { + assert(!Object.hasOwn(process.env, name), 'A child inherited runner credentials.'); +} +process.stdout.write('credentials-private'); +`, + ); + const hooks = path.join(root, 'hooks.mjs'); + fs.writeFileSync( + hooks, + `import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +const probe = () => { + assert.equal(execFileSync(process.execPath, [fileURLToPath(new URL('./credential-child.cjs', import.meta.url))], { encoding: 'utf8' }), 'credentials-private'); +}; +probe(); +export default { onPrepare: probe, onWorkerStart: probe, onWorkerEnd: probe, beforeSession: probe }; +`, + ); fs.writeFileSync( spec, mode === 'worker-crash' ? "describe('crashed worker', () => { it('leaves its session for the parent to drain', () => { process.exit(17); }); });" : [ + "import { execFileSync } from 'node:child_process';", + "import { fileURLToPath } from 'node:url';", "describe('protected runner', () => {", " it('uses the worker-owned native session', async () => {", - ' const grant = process.env.DESKTOP_WDIO_WORKER_TOKEN;', - " expect(typeof grant).toBe('string');", - ' expect(JSON.stringify(browser.options)).not.toContain(grant);', - ' expect(JSON.stringify(browser.capabilities)).not.toContain(grant);', - " console.log('redaction probe', grant, process.env.DESKTOP_WDIO_BROKER_TOKEN);", + " for (const name of ['DESKTOP_WDIO_BROKER_URL', 'DESKTOP_WDIO_BROKER_TOKEN', 'DESKTOP_WDIO_WORKER_TOKEN']) {", + ' expect(Object.hasOwn(process.env, name)).toBeFalse();', + ' }', + ' const credential = /fdg1\\.[0-9a-f]{32}\\.[A-Za-z0-9_-]{43}/;', + ' expect(credential.test(JSON.stringify(browser.options))).toBeFalse();', + ' expect(credential.test(JSON.stringify(browser.capabilities))).toBeFalse();', + " const child = execFileSync(process.execPath, [fileURLToPath(new URL('./credential-child.cjs', import.meta.url))], { encoding: 'utf8' });", + " expect(child).toBe('credentials-private');", + " console.log('child credential isolation confirmed');", " expect(await (await browser.$('~button-primary')).isDisplayed()).toBeTrue();", ' });', '});', ].join('\n'), ); const config = { - config: { schemaVersion: 1, targets: { win32: target }, suites: { fixture: { kind: 'wdio', framework: 'jasmine', specs: [spec] } } }, + config: { + schemaVersion: 1, + targets: { win32: target }, + suites: { fixture: { kind: 'wdio', framework: 'jasmine', specs: [spec], hooks } }, + }, projectRoot: packageRoot, configPath: target.configPath, targets: { win32: target }, @@ -162,7 +195,7 @@ async function main() { assert(!grantPattern.test(content), `no grant reaches ${entry.name}`); } } - if (mode === 'handoff') assert(retained.includes('redaction probe [REDACTED]'), 'deliberately printed worker credentials are redacted'); + if (mode === 'handoff') assert(retained.includes('child credential isolation confirmed'), 'the spec child credential scan executed'); await service.close(); await serviceReport.finish(); assert(!fs.existsSync(path.join(locks, 'interactive.lock'))); diff --git a/packages/agentic/desktop-test/src/wdio/runnerConnection.ts b/packages/agentic/desktop-test/src/wdio/runnerConnection.ts index 5bf5fb612b..153e67a257 100644 --- a/packages/agentic/desktop-test/src/wdio/runnerConnection.ts +++ b/packages/agentic/desktop-test/src/wdio/runnerConnection.ts @@ -194,9 +194,11 @@ function answer(response: ServerResponse, status: number, value: unknown): void } /** Launcher hooks only. The worker's grant is never put in the configuration object WDIO serializes. */ -export async function requestDesktopRunnerWorker(method: 'POST' | 'DELETE', id: string): Promise { - const url = process.env[runnerBrokerUrlEnvironment]; - const token = process.env[runnerBrokerTokenEnvironment]; +export async function requestDesktopRunnerWorker( + method: 'POST' | 'DELETE', + id: string, + { url, token }: { url?: string; token?: string }, +): Promise { if (!url || !token || !/^[0-9]{1,8}-[0-9]{1,8}$/.test(id)) throw new Error('Desktop runner worker authority is unavailable.'); let value: unknown; try { From 3326e0114e46b3bed34860fe91b9d60254d7d2ac Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 12 Nov 2026 03:07:50 +0000 Subject: [PATCH 08/23] Record reviewed handoff fixes and passing qualification Record default-TMPDIR regressions, complete locked package suites, credential inheritance coverage, and the passing clean-cache 178-task aggregate. Retain the native Accessibility block and Windows qualification gap without repeating the blocked contract. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../references/current-status.md | 12 +++-- .../references/current-status.md | 13 +++-- .../desktop-test/references/current-status.md | 48 +++++++++++++++++-- 3 files changed, 62 insertions(+), 11 deletions(-) diff --git a/packages/agentic/desktop-driver-mcp/references/current-status.md b/packages/agentic/desktop-driver-mcp/references/current-status.md index b6d6036e56..a2ec560a64 100644 --- a/packages/agentic/desktop-driver-mcp/references/current-status.md +++ b/packages/agentic/desktop-driver-mcp/references/current-status.md @@ -342,14 +342,19 @@ recorded in `packages/agentic/desktop-test/references/current-status.md`. ## F11 explicit relinquish — 2026-11-12 -Source `0ebf8f453` includes explicit `desktop_session` relinquish with an +Source `5c9d0191e` includes explicit `desktop_session` relinquish with an immediate admission fence, cancellation, and awaited connection-subtree drain. A failed drain retains retryable cleanup responsibility. A fresh session after relinquish rejects old references, cursors, proofs and artifact links; no cross-connection resume or replay is added. -Package format, lint, build, and all 31 tests passed, including +Authoritative rejection discards the cached child binding. After operator +preemption and runner drain, the same MCP connection delegates a fresh child; +old references, cursors, proofs and artifact links remain invalid. +Package format, lint, build, and all 32 tests passed, including deterministic resource invalidation, late/lost acquisition and failed-drain -cases. These are portable semantics, not native qualification. +cases. The default-`TMPDIR` focused server selection passed 31 cases, +including the preempt → runner drain → fresh MCP session regression. +These are portable semantics, not native qualification. The real private-descriptor AppKit consumer at `87bc8b710` reached verified discovery but failed native session creation on macOS 27.2.1 arm64: @@ -358,6 +363,7 @@ helper doctor reported Accessibility/PostEvent `false`; no privacy settings were changed. Native relinquish/handoff and worker-crash drain did not run. Both descriptors and the workload claim were removed; the externally owned fixture survived service shutdown and only its spawning test stopped it. +The Accessibility-blocked native contract was not repeated for review fixes. Exact commands, revisions, one corrected-fixture retry, cleanup evidence and local artifact locators are in diff --git a/packages/agentic/desktop-driver/references/current-status.md b/packages/agentic/desktop-driver/references/current-status.md index 14130f2e49..24eda64e6c 100644 --- a/packages/agentic/desktop-driver/references/current-status.md +++ b/packages/agentic/desktop-driver/references/current-status.md @@ -476,14 +476,20 @@ same Partial result. Physical Ctrl+C and DPI changes were not exercised. ## F11 scoped runner handoff — 2026-11-12 -Source `0ebf8f453` implements the generic attach-only phase: attenuated +Source `5c9d0191e` implements the reviewed generic attach-only phase: attenuated runner/controller grants, bounded busy by default, operator-authorized preemption, and target reservation only after in-flight acquisition, session deletion, and controller-subtree drain settle. Revocation callbacks alone are not a drain barrier. Failed cleanup remains fenced and retryable; unknown runner input is rejected before granting or reserving authority. -Driver package format, lint, build, and tests passed (1,229 passed, -66 skipped); the focused handoff suite passed seven cases. +Settled drains restore the 4096 live/draining grant capacity; only the last +4096 settled cleanup proofs are retained, never pending or failed proof. +Busy/failed attempts issue no runner grant, and shared descriptor/workload +holders require relinquish instead of subtree preemption. Retired requests +still check live authority after asynchronous target probes. +Driver package format, lint, build, and tests passed (1,233 passed, +66 skipped). The default-`TMPDIR` focused authority/delegation/handoff selection +passed 51 cases, including 4,101 drained delegation cycles under one live parent. Native selection at `87bc8b710` on macOS 27.2.1 arm64 passed 27 identity and backend cases, with 15 Windows cases skipped. The stable-signed helper @@ -493,6 +499,7 @@ supported click input mode. Native handoff and worker-crash drain remain unqualified; no permissions were changed. Cleanup preserved the externally owned fixture through service shutdown, removed both descriptors and released the claim before its spawning test ended that exact process. +The Accessibility-blocked native contract was not repeated for review fixes. Exact commands, helper/fixture revisions, the initial stale-fixture failure, one corrected-fixture retry, cleanup evidence and artifact locators are in diff --git a/packages/agentic/desktop-test/references/current-status.md b/packages/agentic/desktop-test/references/current-status.md index f81eb2c0a0..31c3f039a4 100644 --- a/packages/agentic/desktop-test/references/current-status.md +++ b/packages/agentic/desktop-test/references/current-status.md @@ -1029,23 +1029,61 @@ documentation follow-up's CI passes and the coordinator changes its status. ## F11 generic attach-only handoff — 2026-11-12 -Implementation source: `0ebf8f453` (including `4aafeeabb` and `87bc8b710`). +Reviewed implementation source: `5c9d0191e` (including `4aafeeabb` and `87bc8b710`). Native selection ran at `87bc8b710`. This is the generic attached-application phase of [#4448](https://github.com/microsoft/fluentui-react-native/issues/4448), not the blocked Storybook integration or retained-launch phases. **Confirmed, portable:** driver, harness, and MCP package format, lint, and -build checks passed. Driver tests passed 1,229 with 66 skipped; MCP tests -passed 31. The real WDIO deterministic normal-handoff and worker-crash tests +build checks passed. Driver tests passed 1,233 with 66 skipped; MCP tests +passed 32; the locked complete harness suite passed 382 with two skipped. +The real WDIO deterministic normal-handoff and worker-crash tests passed, including a lost runner-grant response, target exclusion, final subtree drain, fresh-session resources, and credential confinement. -The final unlocked harness run passed 380, failed two default-claim CLI +An earlier unlocked harness run passed 380, failed two default-claim CLI contracts, and skipped two; the lane failure explicitly reported another workload holding `interactive.lock`. One focused retry of the two failed CLI contracts under the shared native lock passed both (28 unselected cases skipped). This collision is not native handoff evidence. -The final locked `TF_BUILD=1 yarn lage buildci --concurrency 4` at +**Review fixes:** live/draining grant capacity is reusable beyond 4096 +connections; bounded settled history never evicts pending/failed cleanup +proof. Busy/failed handoffs issue no runner grant. Descriptor roots and +workload/recovery holders refuse preempt with `target-busy` and an explicit +relinquish requirement. A preempted MCP child is discarded after authoritative +rejection so the same connection can reopen with fresh resources after runner +drain. + +Broker credentials are captured and scrubbed before user hook import. WDIO +receives each worker token through its per-worker `runnerEnv`, not launcher +`process.env`; workers scrub it before hooks/specs and their children execute. +Real hook-import, launcher-hook, worker-hook and spec-child probes pass. +The worker's Node bootstrap/preload window remains trusted because its child +token initially arrives in the fork environment; this is not same-account +hostile-code isolation. + +The focused commands below passed using the untouched default `TMPDIR`: + +| Exact command | Result | +| -------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | +| `yarn workspace @fluentui-react-native/desktop-driver test 'authority/(grants\|delegation)\|server/(runnerHandoff\|delegation)' --runInBand` | Passed 51; includes 4,101 drained delegation cycles and root fencing. | +| `yarn workspace @fluentui-react-native/desktop-driver-mcp test server --runInBand` | Passed 31; includes preempt/reopen and stale-resource refusal. | +| `yarn workspace @fluentui-react-native/desktop-test test runner --runInBand` | Passed two; skipped the native opt-in case. Shared native lock used. | + +Review-validation logs are under `artifacts/desktop/f11/review/`. The complete +driver and harness suites used the shared native lock. Full-suite scratch was +project-local with matching `GIT_CEILING_DIRECTORIES`; no native opt-ins were set. +The Accessibility-blocked native contract was deliberately not repeated. + +After review fixes, `yarn lage test-links --verbose --grouped` passed all +90 tasks. Then `yarn clean-lage-cache` passed and the locked +`TF_BUILD=1 yarn lage buildci --concurrency 4` passed all 178 tasks in +2m 2.60s, with no pending or aborted tasks. Source was `5c9d0191e`; the +matching project-local `TMPDIR`/`GIT_CEILING_DIRECTORIES` isolation and native +opt-in skips were unchanged. Log: `artifacts/desktop/f11/review/buildci.log`. +The remaining ledger update records results only, not additional source changes. + +The earlier locked `TF_BUILD=1 yarn lage buildci --concurrency 4` at `17ee49fd1` passed all 178 tasks, including the complete harness test task. Project-local ignored `TMPDIR` also required `GIT_CEILING_DIRECTORIES` set to that directory so temporary instruction-check fixtures did not discover From 9a077fc12925f24731fe320239710ad50e7bf70d Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 12 Nov 2026 06:48:27 -0800 Subject: [PATCH 09/23] docs: record Windows F11 handoff qualification Record Windows Fabric and Win32 Paper handoff evidence and the remaining Windows native consumer CI coverage gap. No implementation changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3be5f341-831b-40d4-9582-337c8ea65709 --- .../references/current-status.md | 100 ++++++++++++++ .../references/current-status.md | 116 ++++++++++++++++ .../desktop-test/references/current-status.md | 128 ++++++++++++++++++ 3 files changed, 344 insertions(+) diff --git a/packages/agentic/desktop-driver-mcp/references/current-status.md b/packages/agentic/desktop-driver-mcp/references/current-status.md index a2ec560a64..ebfe2fc6bb 100644 --- a/packages/agentic/desktop-driver-mcp/references/current-status.md +++ b/packages/agentic/desktop-driver-mcp/references/current-status.md @@ -371,3 +371,103 @@ and local artifact locators are in Windows/Win32 native handoff is not covered by the existing PR smoke/E2E selection and remains not run. Storybook integration and retained-launch ownership remain separate follow-up phases. + +## Windows Fabric and Win32 Paper — F11 handoff qualification — 2026-11-12 UTC + +**Observed:** source `c9753208d0f5602d28510d84ededf3f2fc3c3855`; +Windows x64, active/unlocked RDP (`sessionKind: remote`). Live runs were +inside W1's campaign and the machine-wide `with-native-lock.ps1 -Lane W1 +-RequirePhysical` wrapper; before/after session gates stayed eligible. +No physical-input delivery is claimed. + +The real MCP SDK/server used `connectWithDescriptor`, native Windows listener +verification and a separate attenuated controller child. The generic +`DesktopSupervisor` published agent/runner descriptors in the validated +default private store. It served externally owned apps, not the unsupported +Storybook integration handoff. From the worktree root, the retained +reproduction commands are: + +```powershell +node artifacts\desktop\f11\w1\qualify.cjs windows +node artifacts\desktop\f11\w1\qualify.cjs win32 +``` + +Windows attached to the exact registered packaged Storybook AUMID (Fabric), +with Metro from this worktree using the app's declared +`yarn start --no-interactive --port 8081`. The registered build at +`bdeb0b291` was reused because the native-source comparison found only +native-core changelog/version metadata changes. Win32 attached to the exact +resolved prebuilt REX `ReactTest.exe` (Paper), after the app's declared +`yarn storybook bundle --win32` passed. The public native launcher maintained +application ownership separately from the agent service. + +| Native MCP/runner case | Windows Fabric | Win32 Paper | +| --------------------------------------------------- | --------------------------------------------------------------- | --------------------------------------------------------------- | +| Runner defaults to busy while MCP owns a session | Passed (`target-busy`) | Passed (`target-busy`) | +| `desktop_session` explicit relinquish | Passed; returned confirmed relinquish | Passed; returned confirmed relinquish | +| Operator-authorized preemption | Passed; old adapter binding refused, then runner-exclusive busy | Passed; old adapter binding refused, then runner-exclusive busy | +| Normal runner drain and fresh MCP reopen | Passed | Passed | +| Identity-checked, literal-PID worker kill and drain | Passed recovery; intentionally failed WDIO run | Passed recovery; intentionally failed WDIO run | +| Lost real HTTP 200 runner-grant response | Passed; parent drain restored admission | Passed; parent drain restored admission | +| Old reference/cursor/capture-link rejection | Passed | Passed | + +During preemption the old adapter operation reported `service-unavailable`; +an attempted open then returned `target-busy` during runner ownership. +After runner cleanup, the same MCP connection opened a new child/session. +No old session or resources were resumed or replayed. Lost-response injection +discarded the real native service's acquisition-response body before the typed +client received it; it did not substitute a fake listener. + +| Endpoint | Local evidence root | Normal / preempt / crash run IDs | +| -------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| Windows Fabric | `artifacts\desktop\f11\w1\windows-3tYoml` | `a3bf02e2-df22-4a63-92f8-686057ef2a80` / `24d38403-b4e5-49d6-9e0f-d1d123e4fb17` / `8f702133-e18a-41f0-b05d-ce5fcb5cff1a` | +| Win32 Paper | `artifacts\desktop\f11\w1\win32-BefbqE` | `d79fd672-df2d-4943-8b40-c017a8978380` / `f1f17781-08aa-4cac-a6d8-34cb13bd749b` / `a09a8c20-1380-4933-a7a3-9c2d786352c3` | + +Each root retains `evidence.json`, `run.json`, command logs and crash-process +identity. Normal/preempt runs each have one selected/executed/passed test, +zero skips and `terminal: true`. Crash runs are terminal **failed**, with one +selected, zero completed and one incomplete test: killing workers `34752` +(Windows) and `29856` (Win32) intentionally prevented the final reporting +handshake. Parent cleanup completed and fresh MCP attach proved reuse. +Both service lifecycle reports are terminal passed; both service workload +claims and descriptors were removed. The same external app process +incarnation survived service shutdown, then its recorded launcher closed it. +No processes survived; audit: `FURN-AUDIT: no FURN desktop processes found`. +The campaign is ended. + +Native worker environment checks found no broker/worker credential variables; +model output and a scan of 45 retained text/config/report files contained no +bearer credential. This is observed confinement, not isolation from hostile +same-account code or Node preloads. Helper artifact: +`d8ee05e4796fb3aa22f7b5dbf481bd8cdfb0c20b6d5d24676d689dc7900d5b3c`. + +**Observed, supporting checks:** `yarn build` passed. With native opt-ins unset, +uncached `yarn workspace @fluentui-react-native/desktop-driver-mcp test +--runInBand` passed all 32; driver passed 1,211/skipped 88; harness passed +376/skipped eight. Logs are under `artifacts\desktop\f11\w1`. The additional +Windows native identity/backend selection passed 23 and skipped 19 macOS +cases, including all 15 Windows cases skipped by the earlier macOS selection. +The protected-service selection passed three; its recovering-quarantine case +was **failed/environment-blocked** by project scratch ancestry (`ancestor`). +Default protected storage opened and supported both live runs; no ACL change +was made. Windows permission diagnostics are **unsupported**, not passed. + +**Remaining CI gap:** desktop-test's native MCP/WDIO consumer is darwin-gated, +with a macOS-only fixture. On Windows, `FURN_NATIVE_DRIVER_TEST=1 yarn +workspace @fluentui-react-native/desktop-test test nativeRunner.contract +--runInBand` skips its sole case, including a locked repeat with the fixture +path opt-in naming the installed REX executable. Both plain endpoint +`desktop-driver doctor` commands passed (`ready: true`); their `--permissions` +variants are unsupported. Driver native contracts/Storybook smoke +selection do not cover this consumer handoff. A Windows fixture and explicit +consumer selection are handed back for implementation; no source or CI fix +was made here. The existing three-package `scoped-runner-handoff` patch +changeset remains. Storybook handoff, retained launches and cross-connection +resume/replay remain separate phases. + +**Evidence validation:** `yarn markdown-link-check -c +.github\markdown-link-check-config.json +packages\agentic\desktop-driver-mcp\references\current-status.md` passed +(six links). Root `yarn oxfmt --check`, `yarn change:check` and +`git diff --check` passed. No clean-cache `buildci` rerun was required for +these evidence-only markdown edits. diff --git a/packages/agentic/desktop-driver/references/current-status.md b/packages/agentic/desktop-driver/references/current-status.md index 24eda64e6c..41fb171cf3 100644 --- a/packages/agentic/desktop-driver/references/current-status.md +++ b/packages/agentic/desktop-driver/references/current-status.md @@ -507,3 +507,119 @@ one corrected-fixture retry, cleanup evidence and artifact locators are in Windows/Win32 native handoff is not selected by existing PR smoke/E2E jobs and remains not run. Storybook integration, retained launches, and cross-connection resume/replay are not implemented by this phase. + +## Windows Fabric and Win32 Paper — F11 handoff qualification — 2026-11-12 UTC + +**Observed:** source `c9753208d0f5602d28510d84ededf3f2fc3c3855`, +Windows x64, active/unlocked RDP (`sessionKind: remote`). Every native command +ran inside the W1 campaign through `with-native-lock.ps1 -Lane W1`; native +contracts and live endpoint runs used `-RequirePhysical`. Both session gates +remained eligible. No physical input was delivered by the handoff probes; +these results are not physical-input qualification. + +From this package, with `FURN_NATIVE_DRIVER_TEST=1`: + +```powershell +yarn test --runInBand serviceIdentity.contract applicationIncarnation.contract nativeDriver --json --outputFile \artifacts\desktop\f11\w1\native-contracts.json +yarn test --runInBand protectedService.contract --json --outputFile \artifacts\desktop\f11\w1\protected-service.json +``` + +The first selection **passed 23, skipped 19, failed zero**. All 15 Windows +cases skipped in the earlier macOS selection executed and passed here: + +| Windows case | Result | +| ------------------------------------------------------------------------- | ------ | +| Service identity: describes the publishing supervisor process | Passed | +| Service identity: refuses nonexistent/exited/foreign-principal processes | Passed | +| Service identity: verifies only the process owning the loopback listener | Passed | +| Service identity: refuses changed start time, image or session | Passed | +| Service identity: compares executable paths case-insensitively | Passed | +| Service identity: refuses an unserved address | Passed | +| Service identity: refuses an exited process | Passed | +| Service identity: refuses `http://localhost:49152` | Passed | +| Service identity: refuses `http://192.0.2.1:49152` | Passed | +| Service identity: refuses `https://127.0.0.1:49152` | Passed | +| Service identity: refuses `http://127.0.0.1` | Passed | +| Service identity: refuses `http://127.0.0.1:49152/status` | Passed | +| Service identity: keeps IPv6 and IPv4 listeners apart | Passed | +| Application incarnation: proves exact-process exit, not a weaker identity | Passed | +| Native driver: builds/reuses/resolves/handshakes the Windows helper | Passed | + +The other eight passing cases are portable native-driver source/build-policy +checks. All 19 skips are macOS-only: 16 service-identity cases, one +application-incarnation case and two helper-build/signing cases. They are +**skipped**, not Windows passes. Every exact test title, source file, outcome, +skip reason and mapping to the 15 earlier skips is retained in +`artifacts\desktop\f11\w1\case-mapping.json`; the raw Jest results and +`native-contracts.log` are alongside it. + +`protectedService.contract` was included because it exercises changed +session-owner admission, busy, revocation and drain paths with a real helper. +Three cases **passed**: grant/owner isolation, revoked-owner handoff, and +quarantine retention without exit proof. The recovering-quarantine case +**failed before its assertions, environment-blocked**: the project-local +scratch directory's ancestry is refused by Windows private storage +(`reason: ancestor`). A native diagnostic reproduced that refusal and opened +the default private store successfully (`storage-probe.json`). No ACL was +changed. Both the Node storage adapter and native storage implementation are +unchanged from this branch's main base. **Inferred:** this is storage +placement, not an F11 regression. Recovery in that additional contract remains +unqualified by this run. Other native fixture suites were not repeated: +F11 changes no native protocol/provider code, and live RN endpoints below +exercise the changed generic handoff. + +**Observed, live native:** Windows Fabric packaged Storybook and Win32 Paper +REX each passed default busy, explicit MCP relinquish, operator preemption, +normal worker drain, killed-worker drain, and real lost-acquisition-response +drain. A fresh MCP session succeeded after each runner; stale references, +observation cursors and captured-resource links were rejected. Preemption +invalidated the old binding (`service-unavailable` at the MCP adapter), kept +MCP busy while the runner held the reservation, then allowed a fresh child. + +| Endpoint | Local evidence root | Terminal runner runs: normal / preempt / crash | +| -------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Windows Fabric | `artifacts\desktop\f11\w1\windows-3tYoml` | `a3bf02e2-df22-4a63-92f8-686057ef2a80` passed / `24d38403-b4e5-49d6-9e0f-d1d123e4fb17` passed / `8f702133-e18a-41f0-b05d-ce5fcb5cff1a` failed as injected | +| Win32 Paper | `artifacts\desktop\f11\w1\win32-BefbqE` | `d79fd672-df2d-4943-8b40-c017a8978380` passed / `f1f17781-08aa-4cac-a6d8-34cb13bd749b` passed / `a09a8c20-1380-4933-a7a3-9c2d786352c3` failed as injected | + +The retained reproduction is `node artifacts\desktop\f11\w1\qualify.cjs +windows` or `... win32`, from the worktree root under the W1 physical/session +lock. It composes the real `DesktopSupervisor`, verified native descriptors, +MCP SDK/server and WDIO/Jasmine, mirroring the macOS fixture procedure rather +than using the unsupported Storybook handoff. Apps are externally launched +through the public native host with exact AUMID/REX identity. Windows Metro +uses the app's declared `yarn start --no-interactive --port 8081`, from this +worktree; Win32 uses `yarn storybook bundle --win32`. The registered Fabric +build at `bdeb0b291` was reused: the requested native-source diff contains only +native-core changelog/version metadata, no Windows native source changes. + +Helper artifact: `d8ee05e4796fb3aa22f7b5dbf481bd8cdfb0c20b6d5d24676d689dc7900d5b3c`. +Both external applications remained the same running process incarnation +through service shutdown; only their exact recorded launcher then closed them. +Both descriptors and workload claims were removed. All `run.json` files are +terminal, including the deliberately failed crash runs (one selected, +zero completed, one incomplete; cleanup succeeded). The service lifecycle +runs passed. A scan of 45 retained text/config/report files found no bearer +credential. Final audit: `FURN-AUDIT: no FURN desktop processes found`; +campaign ended. + +**Observed, portable:** uncached `yarn workspace +@fluentui-react-native/desktop-driver test --runInBand` passed 1,211, skipped +88, failed zero with native opt-ins unset. `yarn build` passed. Windows +`yarn desktop-driver doctor --platform windows` and `--platform win32` +each **passed** with `ready: true`; adding `--permissions` is **unsupported** +(exit 1), not a permission pass. +The existing three-package `scoped-runner-handoff` patch changeset is retained. + +**Remaining automation gap:** desktop-test's `nativeRunner.contract` is +darwin-gated and its fixture uses `Contents/MacOS/fixture`; Windows execution +selects one skipped case even with the native opt-in. The CI native-driver +step runs driver contracts, not this generic MCP/WDIO consumer. A Windows +fixture path and explicit consumer selection are proposed to the coordinator; +no test/source or CI code was changed by this qualification lane. + +**Evidence validation:** `yarn markdown-link-check -c +.github\markdown-link-check-config.json +packages\agentic\desktop-driver\references\current-status.md` passed +(six links). Root `yarn oxfmt --check`, `yarn change:check` and +`git diff --check` passed. No clean-cache `buildci` rerun was required for +these evidence-only markdown edits. diff --git a/packages/agentic/desktop-test/references/current-status.md b/packages/agentic/desktop-test/references/current-status.md index 31c3f039a4..b6aac35548 100644 --- a/packages/agentic/desktop-test/references/current-status.md +++ b/packages/agentic/desktop-test/references/current-status.md @@ -1123,3 +1123,131 @@ Windows/Win32 E2E and Storybook smoke jobs do not select this generic descriptor-path contract; Windows CI is **not sufficient** for F11. Storybook integration still requires F9, retained launches require explicit F10 ownership, and cross-MCP-connection resume/replay remain unsupported. + +## Windows Fabric and Win32 Paper — F11 handoff qualification — 2026-11-12 UTC + +**Observed:** tested `c9753208d0f5602d28510d84ededf3f2fc3c3855` on +Windows x64, active/unlocked RDP, `sessionKind: remote`. The W1 campaign and +machine-wide native lock serialized all native/live work; the before/after +`-RequirePhysical` gates stayed eligible. The probes perform native +window discovery, observation and capture, not physical input. + +**As-is native consumer contract:** from this package, +`FURN_NATIVE_DRIVER_TEST=1 yarn test nativeRunner.contract --runInBand` +**skipped its one case** on Windows. The guard requires `darwin` and a signed +AppKit fixture; its CJS implementation hardcodes a macOS executable path, +bundle identity, endpoint and controls. Merely changing the Jest platform +guard cannot qualify Windows. `artifacts\desktop\f11\w1\native-runner-gate.json` +records the skip. A locked repeat with **both** opt-ins set, including +`FURN_NATIVE_HANDOFF_APP_PATH` naming the installed REX executable, still +skipped that case (`native-runner-gate-both-optins.json`). +A platform fixture/CI-selection change is proposed separately, +not implemented in this evidence-only lane. + +**Observed, real consumer:** the retained +`artifacts\desktop\f11\w1\qualify.cjs` composes the real supervisor service, +Windows-verified owner-only private descriptors, MCP SDK client/server and +WDIO/Jasmine. Commands, from the worktree root: + +```powershell +node artifacts\desktop\f11\w1\qualify.cjs windows +node artifacts\desktop\f11\w1\qualify.cjs win32 +``` + +Each command ran through `with-native-lock.ps1 -Lane W1 -RequirePhysical` +inside the granted W1 campaign. This is generic externally owned, +attach-only application handoff, **not** Storybook integration handoff. +Windows used the registered Fabric app's exact AUMID, with Metro from this +worktree via the app's declared `yarn start --no-interactive --port 8081`. +Win32 used the resolved prebuilt REX `ReactTest.exe` and its configured +component/title/production bundle, after `yarn storybook bundle --win32` +**passed** (bundle run `f13082d6-4b6c-428f-92e0-00a42035890d`). +The public native host owned each external launch separately from the service. +No native app rebuild was needed: the diff against the registered +`bdeb0b291` build changes no Windows app/native-core native source, only +native-core changelog/version metadata. Win32 native build is **unsupported**. + +| Case | Windows Fabric | Win32 Paper | Observed evidence | +| --------------------------- | ------------------------------------ | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Busy by default | Passed | Passed | Runner grant acquisition refused `target-busy` while MCP held the native session. | +| Explicit relinquish | Passed | Passed | MCP returned `relinquished: true`; WDIO attached; MCP was refused during runner ownership and opened a fresh session afterward. | +| Operator preemption | Passed | Passed | Service opted into preemption; old MCP binding failed closed (`service-unavailable`), then MCP was busy during WDIO and regained a fresh session after drain. | +| Normal runner exit | Passed | Passed | Native WDIO window query completed; each one-test run passed; fresh MCP attach succeeded after awaited runner cleanup. | +| Runner worker crash | Passed recovery; injected run failed | Passed recovery; injected run failed | Identity-checked literal `Stop-Process -Id 34752` / `Stop-Process -Id 29856` ran under the held lock. Parent detected the missing reporting handshake, drain completed and fresh MCP attach succeeded. | +| Lost acquisition response | Passed | Passed | The real service returned HTTP 200 from `/furn/grants/runner`; the transport discarded its body and threw before the typed client received it. Broker cleanup revoked/drained its parent; fresh MCP attach succeeded without knowing the lost runner token. | +| Old resources after handoff | Passed | Passed | Old observation reference/cursor and capture resource link were refused after a fresh session. | +| Credential confinement | Passed observed probes | Passed observed probes | Real worker spec environment contained no broker/worker credential variables; model output and 45 retained text/config/report files contained no bearer credential. Portable hook/spec-child probes also passed. | + +Native evidence roots, each containing `evidence.json`, terminal `run.json` +files, generated WDIO settings and command logs: + +| Endpoint / phase | Run ID | Terminal result | +| ---------------- | -------------------------------------- | ---------------------------------------------------------------- | +| Windows service | `78e18335-571f-41c7-a724-3bab30b41fd1` | Passed lifecycle; no test cases claimed | +| Windows normal | `a3bf02e2-df22-4a63-92f8-686057ef2a80` | Passed: one selected/executed/passed | +| Windows preempt | `24d38403-b4e5-49d6-9e0f-d1d123e4fb17` | Passed: one selected/executed/passed | +| Windows crash | `8f702133-e18a-41f0-b05d-ce5fcb5cff1a` | Failed as injected: one selected, zero completed, one incomplete | +| Win32 service | `f7ef9875-059f-432a-9daa-2bcb1e384917` | Passed lifecycle; no test cases claimed | +| Win32 normal | `d79fd672-df2d-4943-8b40-c017a8978380` | Passed: one selected/executed/passed | +| Win32 preempt | `f1f17781-08aa-4cac-a6d8-34cb13bd749b` | Passed: one selected/executed/passed | +| Win32 crash | `a09a8c20-1380-4933-a7a3-9c2d786352c3` | Failed as injected: one selected, zero completed, one incomplete | + +Windows root: `artifacts\desktop\f11\w1\windows-3tYoml`. +Win32 root: `artifacts\desktop\f11\w1\win32-BefbqE`. +The crash directories retain process identity and the literal-PID kill script. +Both runs used helper +`d8ee05e4796fb3aa22f7b5dbf481bd8cdfb0c20b6d5d24676d689dc7900d5b3c`. +The original external application process/start time remained running through +service shutdown; only its own recorded launcher then closed it. Both private +descriptors and each service workload claim were removed, with no cleanup +failure. The default per-user workload claim was also absent. **Inferred:** +claim release confirms the service's owned-resource settlement checks succeeded; +absence is not an additional persisted claim state. + +One initial Windows reproduction stopped at an overly strict nested +`assert.rejects` comparison in the local evidence harness, before relinquish. +Its `windows-dqAIsk/evidence.json` records complete cleanup; correcting that +assertion required no product change. Two storage-diagnostic invocations +failed because the external lock wrapper split a single command argument; +adding an unused second argument allowed the actual diagnostic to execute. +These are harness failures, not passing product cases. + +**Additional native contracts:** driver identity/backend selection passed +23, skipped 19 macOS cases, including **all 15 Windows cases** skipped in the +earlier macOS run. `protectedService.contract` passed three, with the +recovering-quarantine case **failed/environment-blocked before its assertions**: +project-local scratch ancestry was refused (`ancestor`). The default private +store opened and supported both live passes; no ACL was changed. +Per-case results and reasons: `artifacts\desktop\f11\w1\case-mapping.json`, +`protected-service.json`, `storage-probe.json`. Windows/Win32 +`yarn desktop-driver doctor --platform windows` and `--platform win32` +each **passed**, reporting `ready: true`; `doctor --permissions` returned +**unsupported**, not passed. + +**Portable checks, native opt-ins unset, uncached:** + +| Exact command | Result | +| --------------------------------------------------------------------------- | -------------------------------------- | +| `yarn build` | Passed | +| `yarn workspace @fluentui-react-native/desktop-driver test --runInBand` | Passed 1,211; skipped 88; failed zero | +| `yarn workspace @fluentui-react-native/desktop-test test --runInBand` | Passed 376; skipped eight; failed zero | +| `yarn workspace @fluentui-react-native/desktop-driver-mcp test --runInBand` | Passed 32; skipped zero; failed zero | + +Logs/JSON results are under `artifacts\desktop\f11\w1`. Test scratch was +project-local, with matching `TEMP`, `TMP` and `GIT_CEILING_DIRECTORIES`. +No install was needed. Existing `.changeset/scoped-runner-handoff.md` covers +the three published packages. No product source, test or CI configuration was +changed. Native Storybook handoff, retained launches, cross-connection resume +and replay remain outside F11's generic phase. + +The Windows/Win32 generic consumer remains absent from hosted selection; +Windows driver-native contracts and Storybook smokes do not replace it. +This coverage change is handed back for implementation. W1's campaign is +ended; final audit: `FURN-AUDIT: no FURN desktop processes found`. + +**Evidence validation:** `yarn markdown-link-check -c +.github\markdown-link-check-config.json +packages\agentic\desktop-test\references\current-status.md` passed +(15 links). Root `yarn oxfmt --check`, `yarn change:check` and +`git diff --check` passed. No clean-cache `buildci` rerun was required for +these evidence-only markdown edits. From 9e82307a8bcf8b8870c10901da4400bfd1bc4989 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 12 Nov 2026 07:23:31 -0800 Subject: [PATCH 10/23] test(desktop): cover Windows runner and agent interruption Cover protected agent-serve shutdown, repeated interrupts, failed cleanup fencing and child-first failure. Enable the native MCP/WDIO consumer on Windows with explicit Win32 readiness, preemption, crash and lost-response cases, capability gates and hosted CI selection. The historical Ctrl+C self-wait repair is already present; the new baseline regression passes without another runtime change. Part of #4441, #4448 and #4435. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3be5f341-831b-40d4-9582-337c8ea65709 --- .changeset/windows-native-runner-coverage.md | 5 + .../actions/setup-desktop-driver/action.yml | 44 +++ .github/workflows/pr.yml | 10 + .../fixtures/cli-interrupt/desktop.config.mjs | 103 +++++++ .../fixtures/native-runner/spec.mjs | 36 +++ .../desktop-test/references/commands.md | 6 +- .../desktop-test/references/contributing.md | 11 + .../desktop-test/references/current-status.md | 78 +++++ .../desktop-test/references/lifecycle.md | 13 +- .../references/native-qualification.md | 56 +++- .../desktop-test/src/contracts.contract.cjs | 64 ++++ .../desktop-test/src/contracts.test.ts | 1 + .../src/wdio/nativeRunner.contract.cjs | 276 +++++++++++++----- .../src/wdio/nativeRunner.contract.test.ts | 46 +-- .../src/wdio/nativeRunnerSupport.cjs | 86 ++++++ .../src/wdio/nativeRunnerSupport.test.ts | 113 +++++++ 16 files changed, 844 insertions(+), 104 deletions(-) create mode 100644 .changeset/windows-native-runner-coverage.md create mode 100644 packages/agentic/desktop-test/fixtures/cli-interrupt/desktop.config.mjs create mode 100644 packages/agentic/desktop-test/fixtures/native-runner/spec.mjs create mode 100644 packages/agentic/desktop-test/src/wdio/nativeRunnerSupport.cjs create mode 100644 packages/agentic/desktop-test/src/wdio/nativeRunnerSupport.test.ts diff --git a/.changeset/windows-native-runner-coverage.md b/.changeset/windows-native-runner-coverage.md new file mode 100644 index 0000000000..900e8d8252 --- /dev/null +++ b/.changeset/windows-native-runner-coverage.md @@ -0,0 +1,5 @@ +--- +'@fluentui-react-native/desktop-test': patch +--- + +Add Windows native private-descriptor MCP/WDIO consumer coverage for handoff, operator preemption, worker crash and lost runner-grant response, with explicit fixture readiness and hosted capability gates. Cover terminal agent-serve interruption, repeated signals and fenced failed cleanup without treating semantic SIGINT as physical console input. diff --git a/.github/actions/setup-desktop-driver/action.yml b/.github/actions/setup-desktop-driver/action.yml index bc15c0648a..622e202ecd 100644 --- a/.github/actions/setup-desktop-driver/action.yml +++ b/.github/actions/setup-desktop-driver/action.yml @@ -13,6 +13,10 @@ inputs: description: Run the target-OS native package contract before provisioning. required: false default: 'false' + run-native-runner-contract: + description: Provision the plain Win32 fixture and verify the native MCP/WDIO consumer on Windows. + required: false + default: 'false' macos-signing-identity: description: Pre-provisioned macOS code-signing identity. Leave empty for an ad hoc CI signature. required: false @@ -197,3 +201,43 @@ runs: "FURN_DESKTOP_DRIVER_BUILD_POLICY=never" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + - name: Provision Windows native runner fixture + if: inputs.run-native-runner-contract == 'true' && (inputs.platform == 'windows' || inputs.platform == 'win32') + shell: pwsh + working-directory: ${{ github.workspace }} + run: | + $ErrorActionPreference = 'Stop' + @' + import fs from 'node:fs'; + import path from 'node:path'; + import { pathToFileURL } from 'node:url'; + const module = path.resolve('packages', 'agentic', 'desktop-driver', 'lib', 'native', 'windows', 'win32FixtureSupport.js'); + const { fixtureExecutable } = await import(pathToFileURL(module).href); + const executable = await fixtureExecutable(); + fs.appendFileSync(process.env.GITHUB_ENV, `FURN_NATIVE_HANDOFF_APP_PATH=${executable}\n`); + console.log('Provisioned the plain Win32 native runner fixture.'); + '@ | node --input-type=module + if ($LASTEXITCODE -ne 0) { + throw "Native runner fixture provisioning exited with code $LASTEXITCODE." + } + + - name: Verify Windows native MCP and WDIO consumer + if: inputs.run-native-runner-contract == 'true' && (inputs.platform == 'windows' || inputs.platform == 'win32') + shell: pwsh + working-directory: ${{ github.workspace }} + env: + FURN_NATIVE_DRIVER_TEST: '1' + run: | + $ErrorActionPreference = 'Stop' + $results = Join-Path $env:GITHUB_WORKSPACE 'artifacts\desktop\f11\native-runner-jest.json' + New-Item -ItemType Directory -Force -Path (Split-Path $results) | Out-Null + yarn workspace @fluentui-react-native/desktop-test test nativeRunner.contract --runInBand --json --outputFile $results + if ($LASTEXITCODE -ne 0) { + throw "Native MCP/WDIO consumer contract exited with code $LASTEXITCODE." + } + $report = Get-Content -Raw -Path $results | ConvertFrom-Json + $ownership = @($report.testResults.assertionResults | Where-Object { $_.title -notmatch '\bcapture\b' }) + if ($ownership.Count -ne 4 -or @($ownership | Where-Object { $_.status -ne 'passed' }).Count -ne 0) { + throw 'Native handoff, operator preemption, worker crash and lost-response recovery must all execute and pass.' + } diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 337071ba23..b165dd0435 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -423,6 +423,7 @@ jobs: with: platform: windows run-native-contract: 'true' + run-native-runner-contract: 'true' disabled-input-features: keyboard,physicalClick,wheel - name: Prep Windows Storybook @@ -456,6 +457,10 @@ jobs: path: | apps/storybook/artifacts/windows apps/storybook/artifacts/desktop + artifacts/desktop/f11/native-runner-jest.json + artifacts/desktop/f11/native-*/evidence.json + artifacts/desktop/f11/native-*/*/run.json + artifacts/desktop/f11/native-*/*/events.jsonl ${{ steps.desktop-driver.outputs.doctor-path }} ${{ steps.desktop-driver.outputs.cache-root }}/v1/artifacts/**/build.log ${{ steps.desktop-driver.outputs.cache-root }}/v1/diagnostics/** @@ -534,6 +539,7 @@ jobs: uses: ./.github/actions/setup-desktop-driver with: platform: win32 + run-native-runner-contract: 'true' disabled-input-features: keyboard,physicalClick,wheel - name: Smoke test Win32 Storybook @@ -548,6 +554,10 @@ jobs: path: | apps/storybook/artifacts/win32 apps/storybook/artifacts/desktop + artifacts/desktop/f11/native-runner-jest.json + artifacts/desktop/f11/native-*/evidence.json + artifacts/desktop/f11/native-*/*/run.json + artifacts/desktop/f11/native-*/*/events.jsonl ${{ steps.desktop-driver.outputs.doctor-path }} ${{ steps.desktop-driver.outputs.cache-root }}/v1/artifacts/**/build.log ${{ steps.desktop-driver.outputs.cache-root }}/v1/diagnostics/** diff --git a/packages/agentic/desktop-test/fixtures/cli-interrupt/desktop.config.mjs b/packages/agentic/desktop-test/fixtures/cli-interrupt/desktop.config.mjs new file mode 100644 index 0000000000..465ce4c126 --- /dev/null +++ b/packages/agentic/desktop-test/fixtures/cli-interrupt/desktop.config.mjs @@ -0,0 +1,103 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { DesktopDriverClient } from '@fluentui-react-native/desktop-driver/client'; +import { FakeDesktopHost } from '@fluentui-react-native/desktop-driver/testing'; +import { startDesktopService } from '../../lib/driverService.js'; + +const root = process.env.FURN_INTERRUPT_ROOT; +const scenario = process.env.FURN_INTERRUPT_SCENARIO; +const events = []; +const record = (event) => { + events.push(event); + fs.writeFileSync(path.join(root, 'disposal.json'), JSON.stringify(events)); +}; + +export default { + schemaVersion: 1, + projectRoot: root, + targets: { + win32: { host: { kind: 'installed', ownership: 'external', application: { executablePath: 'not-launched.exe' } } }, + }, + reporting: { artifactsRoot: path.join(root, 'reports') }, + integration: { + name: 'interrupt-fixture', + supportsAgent: true, + validate() { + if (!['stop', 'repeat', 'child-first', 'failed-cleanup'].includes(scenario)) throw new Error('Unknown interrupt fixture scenario.'); + }, + async run({ supervisor, target, agent, onReady }) { + const host = new FakeDesktopHost({ endpoint: 'win32' }); + const closeApplication = host.closeApplication.bind(host); + host.closeApplication = async (...args) => { + record('drain-start'); + if (scenario === 'repeat' && !process.emit('SIGINT', 'SIGINT')) throw new Error('SIGINT handlers were removed before drain.'); + await delay(25); + if (scenario === 'failed-cleanup') throw new Error('Injected session drain failure.'); + await closeApplication(...args); + record('drain-settled'); + }; + const dispose = host.dispose.bind(host); + host.dispose = async (...args) => { + await dispose(...args); + record('host-disposed'); + }; + const publications = new Map(); + const records = new Map(); + let service; + await supervisor.services.contribute( + [ + { + id: 'protected-driver', + ownership: 'owned', + prepare: ({ signal }) => ({ + identity: 'interrupt-fixture-driver', + async acquire() { + service = await startDesktopService(host, { + target: { endpoint: 'win32', id: target.id, platformName: 'windows', launchModes: ['attach'] }, + agent: { + ...agent, + async publish(connection) { + publications.set(connection.descriptor, connection); + const filename = path.join(root, connection.descriptor); + fs.writeFileSync(filename, 'published'); + return async () => { + fs.unlinkSync(filename); + record(`withdrawn:${connection.descriptor}`); + }; + }, + }, + recovery: { + storage: { + read: async (name) => records.get(name), + publish: async (name, contents) => { + if (records.has(name)) throw new Error('Duplicate recovery record.'); + records.set(name, contents); + }, + remove: async (name, contents) => records.get(name) === contents && records.delete(name), + }, + }, + signal, + guard: (work) => supervisor.services.guard(work), + addSecret: (secret) => supervisor.reporter.addSecret(secret), + }); + const connection = publications.get(agent.descriptor); + const client = new DesktopDriverClient({ url: service.url, grant: connection.grant }); + await client.newSession({ alwaysMatch: { 'furn:target': target.id, 'furn:launchMode': 'attach' } }); + }, + dispose: () => service.close(), + }), + }, + ], + { targetId: target.id, targetInstanceId: target.instanceId }, + ); + process.stdout.write(`${JSON.stringify({ type: 'ready', runId: supervisor.reporter.runId })}\n`); + onReady(); + setImmediate(() => { + if (scenario === 'child-first') supervisor.services.fail(new Error('Injected required child exit before SIGINT.')); + process.emit('SIGINT', 'SIGINT'); + }); + await supervisor.services.guard(supervisor.services.waitForStop()); + }, + }, +}; diff --git a/packages/agentic/desktop-test/fixtures/native-runner/spec.mjs b/packages/agentic/desktop-test/fixtures/native-runner/spec.mjs new file mode 100644 index 0000000000..470f057838 --- /dev/null +++ b/packages/agentic/desktop-test/fixtures/native-runner/spec.mjs @@ -0,0 +1,36 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const { desktopIt } = await import(process.env.FURN_NATIVE_HANDOFF_JASMINE_MODULE); +const root = process.env.FURN_NATIVE_HANDOFF_RUN_ROOT; +const recordSession = () => + fs.writeFileSync(path.join(root, 'worker.session.json'), JSON.stringify({ pid: process.pid, sessionId: browser.sessionId })); + +describe('native scoped runner', () => { + if (process.env.FURN_NATIVE_HANDOFF_MODE === 'worker-crash') { + desktopIt('leaves its native session for parent drain', async () => { + expect((await browser.getWindowHandles()).length).toBeGreaterThan(0); + recordSession(); + process.exit(17); + }); + return; + } + desktopIt('holds exclusive control without requiring physical input', async () => { + expect((await browser.getWindowHandles()).length).toBeGreaterThan(0); + recordSession(); + for (const name of ['DESKTOP_WDIO_BROKER_URL', 'DESKTOP_WDIO_BROKER_TOKEN', 'DESKTOP_WDIO_WORKER_TOKEN']) { + expect(Object.hasOwn(process.env, name)).toBeFalse(); + } + const grant = /fdg1\.[0-9a-f]{32}\.[A-Za-z0-9_-]{43}/; + expect(grant.test(JSON.stringify(browser.options))).toBeFalse(); + expect(grant.test(JSON.stringify(browser.capabilities))).toBeFalse(); + fs.writeFileSync(path.join(root, 'worker.ready'), 'ready'); + await browser.waitUntil(() => fs.existsSync(path.join(root, 'worker.continue')), { timeout: 30_000 }); + }); + desktopIt('uses accessibility input when supported', { requires: ['accessibility-click'] }, async () => { + const count = await browser.$(`~${process.env.FURN_NATIVE_HANDOFF_STATUS_ID}`); + const before = await count.getText(); + await (await browser.$(`~${process.env.FURN_NATIVE_HANDOFF_ACTIVATE_ID}`)).click(); + await browser.waitUntil(async () => (await count.getText()) !== before, { timeout: 10_000 }); + }); +}); diff --git a/packages/agentic/desktop-test/references/commands.md b/packages/agentic/desktop-test/references/commands.md index e03c7a765e..6cc665cb2d 100644 --- a/packages/agentic/desktop-test/references/commands.md +++ b/packages/agentic/desktop-test/references/commands.md @@ -48,7 +48,11 @@ Clients create sessions explicitly. Stop any command with Ctrl+C, SIGTERM, or SIGHUP. The first interrupt stops the running stage or suite and every owned service, and the command exits once cleanup completes, including a `serve` whose target is served by an imported -integration. A second interrupt force-kills owned process trees; a third exits +integration. A clean `serve` stop writes a terminal `passed` lifecycle report, +releases its settled claim, and exits 0; an interrupted one-shot is terminal +`incomplete` and exits 1. A child/service failure or failed cleanup remains +`failed` (exit 1), and unsettled cleanup retains a fenced claim. +A second interrupt force-kills owned process trees; a third exits at once and may leave the workload claim behind. See [lifecycle](lifecycle.md#lifecycle-and-ownership). diff --git a/packages/agentic/desktop-test/references/contributing.md b/packages/agentic/desktop-test/references/contributing.md index ab381c6c40..a323f981c8 100644 --- a/packages/agentic/desktop-test/references/contributing.md +++ b/packages/agentic/desktop-test/references/contributing.md @@ -44,3 +44,14 @@ Run `yarn format`, `yarn lint`, `yarn build`, and `yarn test` from the package. Run consumer suites when changing public configuration or lifecycle behavior. If native output or ownership changes, qualify on the affected operating system; JavaScript contracts alone do not qualify native behavior. + +The opt-in generic native MCP/WDIO consumer is +`yarn test nativeRunner.contract --runInBand`, with +`FURN_NATIVE_DRIVER_TEST=1` and an explicitly provisioned +`FURN_NATIVE_HANDOFF_APP_PATH` (signed AppKit bundle on macOS, plain Win32 +fixture executable on Windows). Follow [native qualification](native-qualification.md#generic-attach-only-mcpwdio-handoff) +for readiness, hosted capability gates, safe private storage and evidence. +`yarn test nativeRunnerSupport` checks host selection and readiness without +starting a native helper. `yarn test contracts -t agent-interrupt-cli` tests +semantic CLI interrupts with a protected fake-host service; it is not physical +console qualification. diff --git a/packages/agentic/desktop-test/references/current-status.md b/packages/agentic/desktop-test/references/current-status.md index b6aac35548..4ed8766df0 100644 --- a/packages/agentic/desktop-test/references/current-status.md +++ b/packages/agentic/desktop-test/references/current-status.md @@ -1251,3 +1251,81 @@ packages\agentic\desktop-test\references\current-status.md` passed (15 links). Root `yarn oxfmt --check`, `yarn change:check` and `git diff --check` passed. No clean-cache `buildci` rerun was required for these evidence-only markdown edits. + +## Windows Ctrl+C regression and native consumer selection — 2026-11-12 UTC + +Source: review candidate based on `04d8d8b13177727492ef204cf190840fa8a3f2a1` +for #4491, with the test/CI/documentation changes described here. This is +portable implementation evidence, not a new native qualification. No native +fixture/helper/app was launched and no physical-input campaign was acquired. + +**Observed:** `contracts` mode `agent-interrupt-cli` already passes against +that unchanged baseline. It runs the actual `serve --agent` CLI in a subprocess +with a protected fake-host service and actual descriptor/grant drains. It +checks bounded process exit, terminal `run.json`, exactly one `run-finished`, +descriptor removal and exact workload-claim disposition: + +| Semantic stimulus | Regression result | Terminal report / exit / claim | +| ---------------------------------------- | ----------------- | ---------------------------------------------------------------- | +| First SIGINT after ready | Passed | `passed`, exit 0, released | +| Second SIGINT during session drain | Passed | `passed`, exit 0, released | +| Required child failure before SIGINT | Passed | Expected `failed`, exit 1, released after settled cleanup | +| Session disposal failure during shutdown | Passed | Expected `failed`, exit 1, fenced with unresolved cleanup effect | + +These are in-process `process.emit('SIGINT', 'SIGINT')` dispatches in the CLI +process, not `GenerateConsoleCtrlEvent`, physical SendInput, or a Windows +console broadcast. The historical self-wait repair is already present from +#4478 (`586e4c38545ca3eedccd1c9c817f2f89ce16219d`); no additional runtime +patch is justified by these results. Physical console Ctrl+C on the Windows +Fabric and Win32 Paper `serve --agent` paths remains **Not run** for this +candidate pending review and the exclusive desktop grant. + +**Observed selection regression:** the extracted original native consumer +gate failed the Windows opt-in test (`Expected true; Received false`; four +other selection cases passed). After parameterization, the portable selection, +platform-fixture, hosted-capture-gate and exact native-readiness tests pass. +The macOS bundle executable/ready-file path is preserved. Windows uses the +prebuilt plain Win32 fixture with native exact executable/title/PID and +`1004: Ready` polling, not an invented ready-file option. No native source +or protocol change was needed. + +The native consumer now selects handoff, operator preemption, worker crash +and lost real HTTP-200 runner-grant response as separate cases. Its crash +fixture records the real worker session before exiting, and each mode checks +deletion before reuse, exact external-process incarnation, stale resources, +terminal reports, descriptor withdrawal and claim release. A cleanup-confirmed +failure permits the next case; uncertain cleanup prevents further launches. +Windows records use the validated default private store with unique names, +not unsafe project ancestry or repaired ACLs. + +Both accessible Windows and Win32 Storybook PR jobs now explicitly provision +the fixture and select the generic consumer through +`run-native-runner-contract: 'true'`. The action requires all four ownership +cases to execute and pass; an all-skipped result is rejected. Capture is a +separate capability-gated case, skipped under hosted disabled-input policy; +core transport/ownership still executes without physical input. The plain +fixture is not React Native renderer or Storybook-handoff qualification. + +Implementation checks and retained review artifacts are under +`artifacts\desktop\f11\w1-fix`: + +- `yarn workspace @fluentui-react-native/desktop-test test contracts --runInBand +--testNamePattern agent-interrupt-cli`: **Passed** before the new coverage + changes; `interrupt-first.json` records one Jest contract/four scenarios. +- `yarn workspace @fluentui-react-native/desktop-test test nativeRunnerSupport +--runInBand`: expected **Failed** before the gate fix (`native-selection-red.json`); + the focused combined selection/interrupt run then **Passed** 11, + **Skipped** 35 (`focused-green.json`, including name-filtered cases). +- Package `yarn format`, `yarn lint`, `yarn build`: **Passed**. Action/workflow + YAML and both new embedded PowerShell blocks: **Passed** syntax validation + without executing native code. +- `yarn workspace @fluentui-react-native/desktop-test test --runInBand`: + **Passed** 388, **Skipped** 12, failed zero (`portable-test.json` and + `portable-test.log`). Native opt-ins were unset; temporary data and + `HOME`/`LOCALAPPDATA` claims were isolated under project artifacts. +- New native consumer, optional native capture and physical Ctrl+C: + **Not run**; review and W1 desktop authorization are still required. + Hosted CI: **Not run** for the unpushed candidate. Clean-cache `buildci`: + **Not run**, reserved for final locked validation after review. + +Patch changeset: `.changeset/windows-native-runner-coverage.md`. diff --git a/packages/agentic/desktop-test/references/lifecycle.md b/packages/agentic/desktop-test/references/lifecycle.md index 752eb8b68f..804563d8c0 100644 --- a/packages/agentic/desktop-test/references/lifecycle.md +++ b/packages/agentic/desktop-test/references/lifecycle.md @@ -67,7 +67,18 @@ Interrupts stay handled until cleanup completes. The first SIGINT, SIGTERM, or SIGHUP requests a graceful stop; the second force-kills owned process trees while cleanup continues; the third exits immediately with status 128 plus the signal number and leaves the claim for inspection. An interrupted one-shot run -reports `incomplete`. +reports terminal `incomplete` and exits 1. A clean long-lived `serve` stop is +terminal `passed` and exits 0 after descriptor withdrawal, grant/session drain, +listener and reporting transport close, and exact claim release. A required +child's failure delivered before the interrupt remains a failed run, not a +successful cancellation. Failed disposal retains the fenced claim even though +the terminal report is written and the CLI exits 1. + +On Windows a physical console Ctrl+C reaches every process attached to that +console, so child exit and parent interruption can race. In-process +`process.emit('SIGINT', 'SIGINT')` exercises the installed handler but is only +semantic regression coverage; `child.kill('SIGINT')` on Windows terminates the +child and is not graceful-console or physical-input qualification. The generated-descriptor service bridge allows 60 seconds per command shutdown phase so a nested scope can use its 45-second disposal budget and flush reporting diff --git a/packages/agentic/desktop-test/references/native-qualification.md b/packages/agentic/desktop-test/references/native-qualification.md index 1c862b50e4..96ddf5b1d2 100644 --- a/packages/agentic/desktop-test/references/native-qualification.md +++ b/packages/agentic/desktop-test/references/native-qualification.md @@ -171,7 +171,7 @@ record them as **Not run**. ## Generic attach-only MCP/WDIO handoff This is independent of the Storybook procedure and its renderer/context gates. -Provision the plain AppKit ownership fixture separately, with bundle identifier +On macOS, provision the plain AppKit ownership fixture separately, with bundle identifier `com.microsoft.furn.application-ownership-fixture`, executable `Contents/MacOS/fixture`, `--title`/`--ready-file` arguments, and accessibility identifiers `ownership-fixture-increment`/`ownership-fixture-count`. It must be @@ -194,17 +194,59 @@ It checks bounded busy, explicit relinquish, refusal of MCP while the runner owns the target, fresh sessions/resources, worker-crash drain, credential confinement, and preservation of the fixture's exact PID through service shutdown. Only the test that spawned the fixture terminates it afterward. -Capture/resource-link coverage is explicitly marked unsupported when the -endpoint refuses capture. +Operator preemption and loss of a real HTTP 200 runner-grant response are +separate modes alongside handoff and worker crash. Every mode verifies +attach-only refusal, busy/exclusive admission, deletion before reuse, stale +references/cursors, terminal reports, credential confinement, descriptor +withdrawal, claim release, and preservation of the same external process +incarnation. A failed mode does not suppress later modes when its cleanup is +confirmed; unconfirmed cleanup blocks subsequent launches. + +### Windows fixture and hosted selection + +The same contract runs on Windows against the existing plain Win32 fixture, +not an AppKit bundle or a React Native host. Provision +`furn-win32-fixture.exe` explicitly with the driver package's test-only +`lib/native/windows/win32FixtureSupport.js` `fixtureExecutable()` builder. +This builds into the native cache but does not launch anything. The consumer +test never builds or downloads its fixture. With the verified helper already +provisioned, run from the repository root under the exclusive desktop lock: + +```powershell +$env:FURN_NATIVE_DRIVER_TEST = '1' +$env:FURN_NATIVE_HANDOFF_APP_PATH = '' +yarn workspace @fluentui-react-native/desktop-test test nativeRunner.contract --runInBand +``` + +Windows resolves the canonical executable and unique exact title, waits through +the supported native attach/window/find APIs for that process's `1004` status +control to say `Ready`, then releases the readiness lease without stopping the +external app. No new native ready-file option is required. Core modes use an +offscreen window and accessibility input only; a read-only ownership case still +executes when an input assertion is unsupported. The optional capture mode +uses an onscreen window and is skipped when +`FURN_DESKTOP_DRIVER_DISABLED_INPUT_FEATURES` disables any input feature, matching +the Windows native-contract gate. Capture/resource-link refusal remains +unsupported, never an input or ownership pass. No mode claims physical input. + +Windows descriptors use unique names in the validated default private store. +Project artifact ancestry is not repaired or trusted for private storage. +The `setup-desktop-driver` action's `run-native-runner-contract: 'true'` +explicitly provisions the fixture and selects this consumer after helper +diagnosis. Both accessible Windows and Win32 Storybook PR jobs enable it. +Their selection guard requires all four core modes to execute and pass; +an all-skipped native consumer cannot make this check green. Hosted capture +remains an explicit skipped test. -The normal handoff runs first; a failure stops before the crash scenario. Retain `artifacts/desktop/f11/native-*/evidence.json` and the run reports. Do not copy the private descriptor directory into shared artifacts. Record source revision, fixture revision, helper identity, selected/executed cases, native permission failures, cleanup, and skipped/not-run scenarios separately. -Absent the explicit opt-ins, the test is skipped, not qualified. This contract -does not qualify Windows/Win32, Storybook handoff, retained launches, physical -input, cross-connection resume, or replay. +Absent the explicit opt-ins, the tests are skipped, not qualified. The plain +fixture does not qualify React Native Windows Fabric/Win32 Paper rendering, +Storybook handoff, retained launches, physical input, cross-connection resume, +or replay. Physical console Ctrl+C on both RN endpoints is a separate lifecycle +qualification, not supplied by the semantic CLI regression. ## Windows Fabric and Win32 Paper diff --git a/packages/agentic/desktop-test/src/contracts.contract.cjs b/packages/agentic/desktop-test/src/contracts.contract.cjs index ba3781270a..4b923bfc0e 100644 --- a/packages/agentic/desktop-test/src/contracts.contract.cjs +++ b/packages/agentic/desktop-test/src/contracts.contract.cjs @@ -1894,6 +1894,70 @@ async function main() { logs.some((log) => log.includes('serve --agent requires an integration that supports agents; \\"fixture\\" does not.')), logs.join('\n'), ); + } else if (mode === 'agent-interrupt-cli') { + for (const scenario of ['stop', 'repeat', 'child-first', 'failed-cleanup']) { + const directory = path.join(root, scenario); + const home = path.join(directory, 'home'); + fs.mkdirSync(home, { recursive: true }); + const env = { ...process.env, HOME: home, LOCALAPPDATA: home, FURN_INTERRUPT_ROOT: directory, FURN_INTERRUPT_SCENARIO: scenario }; + for (const name of ['FURN_DESKTOP_REPORT_CONTEXT', 'FURN_DESKTOP_PROCESS_CONTROL', 'DESKTOP_REPORTER', 'DESKTOP_EVENTS']) { + delete env[name]; + } + const child = spawnSync( + process.execPath, + [ + path.join(packageRoot, 'config', 'cli.cjs'), + '--config', + path.join(packageRoot, 'fixtures', 'cli-interrupt', 'desktop.config.mjs'), + '--target', + 'win32', + '--reporter', + 'json', + 'serve', + '--agent', + '--descriptor', + 'interrupt-agent.json', + ], + { cwd: directory, env, encoding: 'utf8', timeout: 15_000, windowsHide: true }, + ); + assert.ifError(child.error); + assert.equal(child.signal, null, `${scenario}: ${child.stderr}`); + const failed = scenario === 'child-first' || scenario === 'failed-cleanup'; + assert.equal(child.status, failed ? 1 : 0, `${scenario}: ${child.stderr}\n${child.stdout}`); + const readiness = JSON.parse(child.stdout.trim()); + assert.equal(readiness.type, 'ready'); + const reports = path.join(directory, 'reports', readiness.runId); + const result = JSON.parse(fs.readFileSync(path.join(reports, 'run.json'), 'utf8')); + assert.equal(result.terminal, true, `${scenario}: ${JSON.stringify(result)}`); + assert.equal(result.status, failed ? 'failed' : 'passed', JSON.stringify(result)); + const events = fs + .readFileSync(path.join(reports, 'events.jsonl'), 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + assert.equal(events.filter(({ kind }) => kind === 'run-finished').length, 1); + assert(!fs.existsSync(path.join(directory, 'interrupt-agent.json'))); + assert(!fs.existsSync(path.join(directory, 'runner-connection.json'))); + const disposal = JSON.parse(fs.readFileSync(path.join(directory, 'disposal.json'), 'utf8')); + assert(disposal.includes('withdrawn:runner-connection.json')); + assert(disposal.indexOf('withdrawn:interrupt-agent.json') < disposal.indexOf('drain-start')); + assert(disposal.includes('host-disposed')); + const claims = + process.platform === 'win32' + ? path.join(home, 'Microsoft', 'FluentUIReactNative', 'desktop-test', 'workload') + : process.platform === 'darwin' + ? path.join(home, 'Library', 'Caches', 'com.microsoft.fluentui-react-native.desktop-test', 'workload') + : path.join(home, '.cache', 'fluentui-react-native', 'desktop-test', 'workload'); + const claim = path.join(claims, 'interactive.lock'); + if (scenario === 'failed-cleanup') { + const ownership = JSON.parse(fs.readFileSync(claim, 'utf8')); + assert.equal(ownership.state, 'fenced'); + assert(ownership.unresolvedEffects.length > 0); + } else { + assert(disposal.includes('drain-settled')); + assert(!fs.existsSync(claim), 'settled interruption must release its exact workload claim'); + } + } } else if (mode === 'lane-cli') { // Each invocation is its own process with no inherited collector, so its run result records the lane it was given. const configPath = path.join(root, 'desktop.config.mjs'); diff --git a/packages/agentic/desktop-test/src/contracts.test.ts b/packages/agentic/desktop-test/src/contracts.test.ts index 6d38d8bd14..02d1e89452 100644 --- a/packages/agentic/desktop-test/src/contracts.test.ts +++ b/packages/agentic/desktop-test/src/contracts.test.ts @@ -15,6 +15,7 @@ describe('neutral desktop harness contracts', () => { 'services', 'lifecycle', 'interrupt-cli', + 'agent-interrupt-cli', 'resources', 'owned-service', 'agent', diff --git a/packages/agentic/desktop-test/src/wdio/nativeRunner.contract.cjs b/packages/agentic/desktop-test/src/wdio/nativeRunner.contract.cjs index b4bba0c80d..060d2e6b79 100644 --- a/packages/agentic/desktop-test/src/wdio/nativeRunner.contract.cjs +++ b/packages/agentic/desktop-test/src/wdio/nativeRunner.contract.cjs @@ -6,9 +6,14 @@ const { createRequire } = require('node:module'); const path = require('node:path'); const { setTimeout: delay } = require('node:timers/promises'); const { pathToFileURL } = require('node:url'); +const { nativeRunnerCaptureEnabled, nativeRunnerEnabled, nativeRunnerFixture, waitForNativeFixture } = require('./nativeRunnerSupport.cjs'); async function main() { const mode = process.argv[2]; + assert(nativeRunnerEnabled(process.platform, process.env), 'Native runner contracts require the supported host and explicit opt-ins.'); + assert(['handoff', 'operator-preempt', 'worker-crash', 'lost-response', 'capture'].includes(mode), 'Unknown native runner scenario.'); + if (mode === 'capture') + assert(nativeRunnerCaptureEnabled(process.platform, process.env), 'Capture is unavailable under this host policy.'); const packageRoot = path.resolve(__dirname, '../..'); const repositoryRoot = path.resolve(packageRoot, '../../..'); const lib = (name) => pathToFileURL(path.join(packageRoot, 'lib', name)).href; @@ -19,45 +24,62 @@ async function main() { const { runDesktopWdioSuite } = await import(lib('wdio/run.js')); const { connectDesktopRunner, createDesktopRunnerBroker } = await import(lib('wdio/runnerConnection.js')); const { resolveDesktopScopeDomains } = await import('@fluentui-react-native/desktop-driver/authoring'); + const { NativeDesktopHost, resolveNativeDesktopDriver } = await import('@fluentui-react-native/desktop-driver'); + const { createMacOSApplicationProbe, createWindowsApplicationProbe, createWindowsPrivateStorage } = + await import('@fluentui-react-native/desktop-driver/authority'); const { connectWithDescriptor } = await import(pathToFileURL(path.join(mcpRoot, 'lib/connection.js')).href); const { createDesktopDriverMcpServer } = await import(pathToFileURL(path.join(mcpRoot, 'lib/server.js')).href); const { Client } = await import(pathToFileURL(mcpRequire.resolve('@modelcontextprotocol/sdk/client/index.js')).href); const { InMemoryTransport } = await import(pathToFileURL(mcpRequire.resolve('@modelcontextprotocol/sdk/inMemory.js')).href); - const parent = path.join(repositoryRoot, 'artifacts/desktop/f11'); + const parent = path.join(repositoryRoot, 'artifacts', 'desktop', 'f11'); fs.mkdirSync(parent, { recursive: true, mode: 0o700 }); const root = fs.mkdtempSync(path.join(parent, `native-${mode}-`)); const evidence = { mode, passed: false, revision: spawnSync('git', ['rev-parse', 'HEAD'], { cwd: repositoryRoot, encoding: 'utf8' }).stdout.trim(), - fixture: 'com.microsoft.furn.application-ownership-fixture', + endpoint: process.platform === 'win32' ? 'win32' : 'macos', fixturePreserved: false, descriptorsRemoved: false, claimReleased: false, + cleanupConfirmed: false, }; let output = ''; const sink = { write: (chunk) => ((output += chunk.toString()), true) }; const reporting = (kind) => new DesktopRunReporter({ artifactsRoot: root, kind, output: sink, errorOutput: sink, summary: false }); - const serviceReport = reporting('lifecycle'); - const run = reporting('tests'); - const privateRoot = path.join(root, 'private'); + // Windows validates every storage ancestor; project scratch need not be owner-only. Use unique records in its + // validated default store, not an ACL repair or a copy of the descriptors into artifacts. + const windows = process.platform === 'win32'; + const privateRoot = windows ? undefined : path.join(root, 'private'); + const agentDescriptor = windows ? `${path.basename(root)}-agent.json` : 'agent-connection.json'; + const runnerDescriptor = windows ? `${path.basename(root)}-runner.json` : 'runner-connection.json'; const locks = path.join(root, 'locks'); const ready = path.join(root, 'fixture.ready'); - const fixture = spawn( - path.join(fs.realpathSync(process.env.FURN_NATIVE_HANDOFF_APP_PATH), 'Contents/MacOS/fixture'), - ['--title', path.basename(root), '--ready-file', ready], - { stdio: 'ignore' }, + const fixturePlan = nativeRunnerFixture( + process.platform, + fs.realpathSync(process.env.FURN_NATIVE_HANDOFF_APP_PATH), + path.basename(root), + ready, + { capture: mode === 'capture' }, ); + const artifact = await resolveNativeDesktopDriver({ platform: fixturePlan.endpoint, buildPolicy: 'never' }); + const storage = windows ? await createWindowsPrivateStorage({ artifact }) : undefined; + const probeApplication = windows ? createWindowsApplicationProbe({ artifact }) : createMacOSApplicationProbe({ artifact }); + const serviceReport = reporting('lifecycle'); + const run = reporting('tests'); + evidence.fixture = fixturePlan.application; + evidence.helper = { artifactId: artifact.artifactId, sourceDigest: artifact.sourceDigest, provider: artifact.provider }; + const fixture = spawn(fixturePlan.executable, fixturePlan.args, { stdio: 'ignore', windowsHide: true }); fixture.once('error', () => undefined); const target = { - endpoint: 'macos', + endpoint: fixturePlan.endpoint, host: { kind: 'installed', ownership: 'external', - application: { bundleIdentifier: evidence.fixture, windowTitle: path.basename(root) }, + application: fixturePlan.application, }, - id: 'native-runner-attached', - instanceId: 'native-runner-attached', + id: windows ? path.basename(root) : 'native-runner-attached', + instanceId: windows ? path.basename(root) : 'native-runner-attached', projectRoot: packageRoot, configPath: path.join(root, 'desktop.config.mjs'), }; @@ -67,13 +89,16 @@ async function main() { lockDirectory: locks, input: 'accessible', recovery: { root: privateRoot }, - agent: { root: privateRoot }, + agent: { root: privateRoot, descriptor: agentDescriptor, runner: { descriptor: runnerDescriptor, allowPreempt: true } }, }); const runner = new DesktopSupervisor({ target, reporter: run, lockDirectory: locks }); const modelResults = []; let client; let server; let cleanup; + let incarnation; + let completed; + let readinessDisposed = !windows; const shutdown = () => (cleanup ??= (async () => { const failures = []; @@ -84,35 +109,74 @@ async function main() { failures.push(error); } } - evidence.fixturePreserved = fixture.exitCode === null && fixture.signalCode === null; - evidence.descriptorsRemoved = - !fs.existsSync(path.join(privateRoot, 'agent-connection.json')) && !fs.existsSync(path.join(privateRoot, 'runner-connection.json')); + try { + evidence.fixturePreserved = + fixture.exitCode === null && + fixture.signalCode === null && + (incarnation === undefined || (await probeApplication(incarnation)) === 'running'); + evidence.descriptorsRemoved = storage + ? (await storage.read(agentDescriptor)) === undefined && (await storage.read(runnerDescriptor)) === undefined + : !fs.existsSync(path.join(privateRoot, agentDescriptor)) && !fs.existsSync(path.join(privateRoot, runnerDescriptor)); + } catch (error) { + failures.push(error); + } evidence.claimReleased = !fs.existsSync(path.join(locks, 'interactive.lock')); - if (fixture.exitCode === null && fixture.signalCode === null) { - const exited = once(fixture, 'exit'); - fixture.kill('SIGTERM'); - await exited; + if (fixture.pid !== undefined && fixture.exitCode === null && fixture.signalCode === null) { + try { + const exited = once(fixture, 'exit', { signal: AbortSignal.timeout(5_000) }); + fixture.kill('SIGTERM'); + await exited; + } catch (error) { + failures.push(error); + } + } + evidence.fixtureExited = fixture.pid === undefined || fixture.exitCode !== null || fixture.signalCode !== null; + if (incarnation && evidence.fixtureExited) { + try { + assert.equal(await probeApplication(incarnation), 'exited'); + } catch (error) { + failures.push(error); + } } - evidence.fixtureExited = fixture.exitCode !== null || fixture.signalCode !== null; for (const report of [run, serviceReport]) { try { - await report.finish(); + if (failures.length) report.fail(new Error('Native fixture cleanup was not confirmed.')); + const result = await report.finish(); + assert.equal(result.terminal, true); + evidence[report === run ? 'run' : 'serviceRun'] = { id: result.runId, status: result.status, counts: result.counts }; } catch (error) { failures.push(error); } } + evidence.readinessHelperDisposed = readinessDisposed; + evidence.cleanupConfirmed = + readinessDisposed && failures.length === 0 && evidence.fixtureExited && evidence.descriptorsRemoved && evidence.claimReleased; fs.writeFileSync(path.join(root, 'evidence.json'), JSON.stringify(evidence, null, 2), { mode: 0o600 }); if (failures.length) throw new AggregateError(failures, 'Native handoff fixture cleanup failed.'); })()); process.once('SIGTERM', () => void shutdown().finally(() => process.exit(143))); try { - await waitFor(ready, fixture); - assert.equal(Number(fs.readFileSync(ready, 'utf8')), fixture.pid); + if (windows) { + incarnation = await waitForNativeFixture({ + host: new NativeDesktopHost({ artifact, application: fixturePlan.application, endpoint: fixturePlan.endpoint }), + target, + fixture, + statusId: fixturePlan.statusId, + onDisposed: () => { + readinessDisposed = true; + }, + }); + evidence.readiness = 'native exact executable/title/PID and Ready control'; + } else { + await waitFor(ready, fixture); + assert.equal(Number(fs.readFileSync(ready, 'utf8')), fixture.pid); + evidence.readiness = 'fixture ready-file/PID'; + } await service.start(); evidence.descriptorPublished = true; - const connection = await connectDesktopRunner({ root: privateRoot }); + const connection = await connectDesktopRunner({ descriptor: runnerDescriptor, root: privateRoot }); server = createDesktopDriverMcpServer({ - connect: connectWithDescriptor({ descriptor: 'agent-connection.json', root: privateRoot }), + connect: connectWithDescriptor({ descriptor: agentDescriptor, root: privateRoot }), version: '0.0.0-native-contract', }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -128,65 +192,103 @@ async function main() { assert(!reply.isError, JSON.stringify(reply)); return JSON.parse(reply.content.find(({ type }) => type === 'text').text); }; + const status = json(await call('desktop_session')); + assert.deepEqual(status.targets.find(({ id }) => id === target.id).launchModes, ['attach']); + assert((await call('desktop_session', { action: 'open', target: target.id, launch: true })).isError); + evidence.attachOnly = true; const original = json(await call('desktop_session', { action: 'open', target: target.id })); - assert.equal(service.server.sessions.get(original.id).lease.processId, fixture.pid); + const originalLease = service.server.sessions.get(original.id).lease; + assert.equal(originalLease.processId, fixture.pid); + if (incarnation) assert.equal(originalLease.processStartedAt, incarnation.processStartedAt); + incarnation = { processId: originalLease.processId, processStartedAt: originalLease.processStartedAt }; + assert.equal(await probeApplication(incarnation), 'running'); + evidence.incarnation = incarnation; evidence.nativeSessionOpened = true; const observed = json(await call('desktop_observe', { observe: { budget: { maxNodes: 1 } } })).result; assert(observed.next); - const found = json(await call('desktop_observe', { find: { automationId: 'ownership-fixture-increment' } })).result; + const found = json(await call('desktop_observe', { find: { automationId: fixturePlan.activateId } })).result; assert.equal(found.matches.length, 1); const stale = found.matches[0].ref; - const capture = await call('desktop_capture'); - const artifact = capture.content.find(({ type }) => type === 'resource_link'); - evidence.capture = artifact ? 'passed' : 'unsupported'; + let captured; + if (!windows || mode === 'capture') { + const capture = await call('desktop_capture'); + captured = capture.content.find(({ type }) => type === 'resource_link'); + evidence.capture = captured ? 'passed' : 'unsupported'; + if (mode === 'capture') assert(captured, 'the selected capture contract must produce its resource link'); + } else { + evidence.capture = nativeRunnerCaptureEnabled(process.platform, process.env) + ? 'not run (separate capture case)' + : 'skipped (host input policy)'; + } await runner.open(false); await assert.rejects(createDesktopRunnerBroker({ connection, targetId: target.id, addSecret: (secret) => run.addSecret(secret) }), { code: 'session not created', }); evidence.defaultBusy = true; - assert.deepEqual(json(await call('desktop_session', { action: 'relinquish' })), { relinquished: true }); - const spec = path.join(root, 'spec.mjs'); + if (mode !== 'operator-preempt') { + assert.deepEqual(json(await call('desktop_session', { action: 'relinquish' })), { relinquished: true }); + assert.throws(() => service.server.sessions.get(original.id), { code: 'invalid session id' }); + } + if (mode === 'lost-response') { + const fetcher = globalThis.fetch; + let dropped = false; + globalThis.fetch = async (input, init) => { + const response = await fetcher(input, init); + if (String(input).endsWith('/furn/grants/runner') && response.status === 200) { + dropped = true; + await response.body?.cancel(); + const busy = await call('desktop_session', { action: 'open', target: target.id }); + assert(busy.isError); + assert.equal(JSON.parse(busy.content[0].text).reason, 'target-busy'); + throw new Error('Injected runner acquisition response loss.'); + } + return response; + }; + try { + await assert.rejects(createDesktopRunnerBroker({ connection, targetId: target.id, addSecret: (secret) => run.addSecret(secret) }), { + message: 'Injected runner acquisition response loss.', + }); + } finally { + globalThis.fetch = fetcher; + } + assert(dropped, 'the fault must discard a real successful runner-grant response'); + const recovered = json(await call('desktop_session', { action: 'open', target: target.id })); + assert.notEqual(recovered.id, original.id); + json(await call('desktop_session', { action: 'close' })); + assert.throws(() => service.server.sessions.get(recovered.id), { code: 'invalid session id' }); + evidence.lostResponseDrained = true; + } + const spec = path.join(packageRoot, 'fixtures', 'native-runner', 'spec.mjs'); process.env.FURN_NATIVE_HANDOFF_RUN_ROOT = root; process.env.FURN_NATIVE_HANDOFF_JASMINE_MODULE = lib('jasmine/index.js'); - fs.writeFileSync( - spec, - mode === 'worker-crash' - ? [ - 'const { desktopIt } = await import(process.env.FURN_NATIVE_HANDOFF_JASMINE_MODULE);', - "desktopIt('leaves its native session for parent drain', () => { process.exit(17); });", - ].join('\n') - : [ - "import fs from 'node:fs';", - "import path from 'node:path';", - 'const { desktopIt } = await import(process.env.FURN_NATIVE_HANDOFF_JASMINE_MODULE);', - "describe('native scoped runner', () => {", - " desktopIt('holds exclusive control and uses accessibility input', { requires: ['accessibility-click'] }, async () => {", - ' const root = process.env.FURN_NATIVE_HANDOFF_RUN_ROOT;', - " fs.writeFileSync(path.join(root, 'worker.ready'), 'ready');", - " await browser.waitUntil(() => fs.existsSync(path.join(root, 'worker.continue')), { timeout: 30000 });", - " const count = await browser.$('~ownership-fixture-count');", - ' const before = await count.getText();', - " await (await browser.$('~ownership-fixture-increment')).click();", - ' expect(await count.getText()).not.toBe(before);', - " console.log('redaction probe', process.env.DESKTOP_WDIO_WORKER_TOKEN, process.env.DESKTOP_WDIO_BROKER_TOKEN);", - ' });', - '});', - ].join('\n'), - ); + process.env.FURN_NATIVE_HANDOFF_MODE = mode; + process.env.FURN_NATIVE_HANDOFF_STATUS_ID = fixturePlan.statusId; + process.env.FURN_NATIVE_HANDOFF_ACTIVATE_ID = fixturePlan.activateId; const config = { - config: { schemaVersion: 1, targets: { macos: target }, suites: { fixture: { kind: 'wdio', framework: 'jasmine', specs: [spec] } } }, + config: { + schemaVersion: 1, + targets: { [target.endpoint]: target }, + suites: { fixture: { kind: 'wdio', framework: 'jasmine', specs: [spec] } }, + }, projectRoot: packageRoot, configPath: target.configPath, - targets: { macos: target }, + targets: { [target.endpoint]: target }, reporting: { artifactsRoot: root }, testScopes: resolveDesktopScopeDomains(), }; - const completed = runDesktopWdioSuite(runner, config, 'fixture', 'accessible', { connection }).then( + completed = runDesktopWdioSuite(runner, config, 'fixture', 'accessible', { connection, preempt: mode === 'operator-preempt' }).then( () => undefined, (error) => error, ); - if (mode === 'handoff') { + if (mode !== 'worker-crash') { await waitFor(path.join(root, 'worker.ready'), fixture); + if (mode === 'operator-preempt') { + const old = await call('desktop_check', { ref: stale, expect: { enabled: true } }); + assert(old.isError); + assert.equal(JSON.parse(old.content[0].text).reason, 'service-unavailable'); + assert.throws(() => service.server.sessions.get(original.id), { code: 'invalid session id' }); + evidence.operatorPreempted = true; + } const busy = await call('desktop_session', { action: 'open', target: target.id }); assert(busy.isError); assert.equal(JSON.parse(busy.content[0].text).reason, 'target-busy'); @@ -196,11 +298,20 @@ async function main() { const failure = await completed; if (mode === 'worker-crash') { assert(failure, 'the crashed native worker must fail its run'); + assert(fs.existsSync(path.join(root, 'worker.session.json')), 'the crash must occur in a worker that opened its native session'); run.fail(failure); } else assert.equal(failure, undefined, failure?.stack); + const worker = JSON.parse(fs.readFileSync(path.join(root, 'worker.session.json'), 'utf8')); + assert.equal(typeof worker.sessionId, 'string'); + assert.throws(() => service.server.sessions.get(worker.sessionId), { code: 'invalid session id' }); + evidence.worker = worker; evidence.workerDrained = true; const fresh = json(await call('desktop_session', { action: 'open', target: target.id })); assert.notEqual(fresh.id, original.id); + assert.notEqual(fresh.id, worker.sessionId); + const freshLease = service.server.sessions.get(fresh.id).lease; + assert.equal(freshLease.processId, incarnation.processId); + assert.equal(freshLease.processStartedAt, incarnation.processStartedAt); const checked = await call('desktop_check', { ref: stale, expect: { enabled: true } }); assert(checked.isError); const page = await call('desktop_observe', { read: { cursor: observed.next } }); @@ -208,7 +319,7 @@ async function main() { assert.equal(JSON.parse(page.content[0].text).reason, 'observation-cursor-expired'); const acted = json(await call('desktop_act', { steps: [{ act: { action: 'activate', ref: stale, sample: found.sample.id } }] })); assert.equal(acted.result.status, 'stopped'); - if (artifact) await assert.rejects(client.readResource({ uri: artifact.uri })); + if (captured) await assert.rejects(client.readResource({ uri: captured.uri })); evidence.staleResourcesRejected = true; json(await call('desktop_session', { action: 'close' })); await runner.close(); @@ -217,30 +328,47 @@ async function main() { assert(!grantPattern.test(JSON.stringify(modelResults))); assert(!grantPattern.test(output)); const result = await run.finish(); + assert.equal(result.terminal, true); assert.equal(result.status, mode === 'worker-crash' ? 'failed' : 'passed'); - for (const entry of fs.readdirSync(run.root, { recursive: true, withFileTypes: true })) { - if (entry.isFile()) assert(!grantPattern.test(fs.readFileSync(path.join(entry.parentPath, entry.name), 'utf8'))); + if (mode !== 'worker-crash') assert(result.counts.executed > 0, 'ownership must execute even when input is unsupported'); + for (const report of [run, serviceReport]) { + for (const entry of fs.readdirSync(report.root, { recursive: true, withFileTypes: true })) { + if (entry.isFile()) assert(!grantPattern.test(fs.readFileSync(path.join(entry.parentPath, entry.name), 'utf8'))); + } } evidence.credentialsConfined = true; } catch (error) { - const message = error instanceof Error ? error.message : 'Native handoff fixture failed.'; + const message = (error instanceof Error ? error.message : 'Native handoff fixture failed.').replace( + /fdg1\.[0-9a-f]{32}\.[A-Za-z0-9_-]{43}/g, + '[redacted]', + ); evidence.error = message; run.fail(error); - throw new Error(`Native ${mode} qualification failed; evidence ${path.join(root, 'evidence.json')}: ${message}`); } finally { - await shutdown(); + try { + await shutdown(); + await completed; + } catch { + evidence.error ??= 'Native fixture cleanup failed.'; + } } - assert(evidence.fixturePreserved, 'the externally owned fixture must survive all service cleanup'); - assert(evidence.fixtureExited && evidence.descriptorsRemoved && evidence.claimReleased); - evidence.passed = true; + evidence.passed = !evidence.error && evidence.fixturePreserved && evidence.cleanupConfirmed; fs.writeFileSync(path.join(root, 'evidence.json'), JSON.stringify(evidence, null, 2), { mode: 0o600 }); - process.stdout.write(JSON.stringify({ mode, passed: true, evidence: path.join(root, 'evidence.json') })); + process.stdout.write( + JSON.stringify({ + mode, + passed: evidence.passed, + cleanupConfirmed: evidence.cleanupConfirmed, + evidence: path.join(root, 'evidence.json'), + }), + ); + if (!evidence.passed) process.exitCode = 1; } async function waitFor(file, fixture) { const deadline = Date.now() + 60_000; while (!fs.existsSync(file)) { - if (fixture.exitCode !== null || fixture.signalCode !== null) + if (fixture.pid === undefined || fixture.exitCode !== null || fixture.signalCode !== null) throw new Error('The native application fixture exited before readiness.'); if (Date.now() >= deadline) throw new Error('The native application fixture did not report readiness.'); await delay(50); diff --git a/packages/agentic/desktop-test/src/wdio/nativeRunner.contract.test.ts b/packages/agentic/desktop-test/src/wdio/nativeRunner.contract.test.ts index 262645e2be..27e79820ce 100644 --- a/packages/agentic/desktop-test/src/wdio/nativeRunner.contract.test.ts +++ b/packages/agentic/desktop-test/src/wdio/nativeRunner.contract.test.ts @@ -1,16 +1,20 @@ import { spawn } from 'node:child_process'; import path from 'node:path'; -const nativeTest = - process.platform === 'darwin' && process.env.FURN_NATIVE_DRIVER_TEST === '1' && process.env.FURN_NATIVE_HANDOFF_APP_PATH - ? test - : test.skip; +const { nativeRunnerEnabled, nativeRunnerCaptureEnabled } = require('./nativeRunnerSupport.cjs'); +let cleanupConfirmed = true; -nativeTest( - 'real private-descriptor MCP/WDIO handoff preserves the attached app and drains a crashed worker', - async () => { - for (const mode of ['handoff', 'worker-crash']) { - const result = await new Promise((resolve, reject) => { +for (const mode of ['handoff', 'operator-preempt', 'worker-crash', 'lost-response', 'capture']) { + const nativeTest = + nativeRunnerEnabled(process.platform, process.env) && (mode !== 'capture' || nativeRunnerCaptureEnabled(process.platform, process.env)) + ? test + : test.skip; + nativeTest( + `real private-descriptor MCP/WDIO ${mode} preserves the attached app and confirms cleanup`, + async () => { + if (!cleanupConfirmed) throw new Error('Not run: the preceding native scenario did not confirm cleanup.'); + cleanupConfirmed = false; + const result = await new Promise<{ cleanupConfirmed?: boolean; passed?: boolean }>((resolve, reject) => { const child = spawn(process.execPath, [path.join(__dirname, 'nativeRunner.contract.cjs'), mode], { stdio: ['ignore', 'pipe', 'pipe'], timeout: 180_000, @@ -21,18 +25,18 @@ nativeTest( child.stderr.on('data', (data: Buffer) => stderr.push(data)); child.once('error', reject); child.once('close', (code) => { - if (code !== 0) reject(new Error(Buffer.concat(stderr).toString('utf8') || Buffer.concat(stdout).toString('utf8'))); - else { - try { - resolve(JSON.parse(Buffer.concat(stdout).toString('utf8'))); - } catch (error) { - reject(error); - } + try { + const value = JSON.parse(Buffer.concat(stdout).toString('utf8')); + if (code !== 0 && value.passed) throw new Error('The native process failed after reporting a pass.'); + resolve(value); + } catch (error) { + reject(new Error(Buffer.concat(stderr).toString('utf8') || String(error))); } }); }); - expect(result).toMatchObject({ mode, passed: true }); - } - }, - 420_000, -); + cleanupConfirmed = result.cleanupConfirmed === true; + expect(result).toMatchObject({ mode, passed: true, cleanupConfirmed: true }); + }, + 210_000, + ); +} diff --git a/packages/agentic/desktop-test/src/wdio/nativeRunnerSupport.cjs b/packages/agentic/desktop-test/src/wdio/nativeRunnerSupport.cjs new file mode 100644 index 0000000000..e7b1a416de --- /dev/null +++ b/packages/agentic/desktop-test/src/wdio/nativeRunnerSupport.cjs @@ -0,0 +1,86 @@ +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { setTimeout: delay } = require('node:timers/promises'); + +function nativeRunnerEnabled(platform, environment) { + return ( + (platform === 'darwin' || platform === 'win32') && + environment.FURN_NATIVE_DRIVER_TEST === '1' && + !!environment.FURN_NATIVE_HANDOFF_APP_PATH + ); +} + +function nativeRunnerCaptureEnabled(platform, environment) { + return platform !== 'win32' || !(environment.FURN_DESKTOP_DRIVER_DISABLED_INPUT_FEATURES ?? '').trim(); +} + +/** + * @returns {{ endpoint: 'macos' | 'win32', executable: string, args: string[], application: import('@fluentui-react-native/desktop-driver').NativeDesktopApplicationDescriptor, activateId: string, statusId: string }} + */ +function nativeRunnerFixture(platform, applicationPath, title, readyFile, { capture = false } = {}) { + if (platform === 'darwin') { + return { + endpoint: 'macos', + executable: path.posix.join(applicationPath, 'Contents/MacOS/fixture'), + args: ['--title', title, '--ready-file', readyFile], + application: { bundleIdentifier: 'com.microsoft.furn.application-ownership-fixture', windowTitle: title }, + activateId: 'ownership-fixture-increment', + statusId: 'ownership-fixture-count', + }; + } + if (platform !== 'win32' || !path.win32.isAbsolute(applicationPath) || path.win32.extname(applicationPath).toLowerCase() !== '.exe') { + throw new Error('The Windows native runner requires an explicitly provisioned Win32 fixture executable.'); + } + return { + endpoint: 'win32', + executable: applicationPath, + args: ['--title', title, '--offscreen', capture ? '0' : '1'], + application: { executablePath: applicationPath, windowTitle: title }, + activateId: '1001', + statusId: '1004', + }; +} + +async function waitForNativeFixture({ host, target, fixture, statusId, onDisposed, timeoutMs = 60_000, intervalMs = 50 }) { + const signal = AbortSignal.timeout(timeoutMs); + let lease; + try { + for (;;) { + if (fixture.pid === undefined || fixture.exitCode !== null || fixture.signalCode !== null) + throw new Error('The native fixture exited before readiness.'); + if (signal.aborted) throw new Error('The native fixture did not expose its ready control before the deadline.'); + if (!lease) { + try { + lease = await host.attach(target, signal); + } catch (error) { + if ( + typeof error !== 'object' || + error === null || + !('code' in error) || + (error.code !== 'session not created' && error.code !== 'attach-failed') + ) + throw error; + } + } + if (lease) { + assert.equal(lease.processId, fixture.pid, 'readiness must belong to the exact fixture launched by this contract'); + const windows = await host.windows(lease, signal); + for (const window of windows) { + if (window.title !== target.host.application.windowTitle) continue; + const controls = await host.find({ windowId: window.id }, { strategy: 'accessibility id', value: statusId }, signal); + if (controls.some((control) => control.automationId === statusId && control.name === 'Ready')) return lease; + } + } + await delay(intervalMs); + } + } finally { + try { + if (lease) await host.closeApplication(lease); + } finally { + await host.dispose(); + onDisposed?.(); + } + } +} + +module.exports = { nativeRunnerEnabled, nativeRunnerCaptureEnabled, nativeRunnerFixture, waitForNativeFixture }; diff --git a/packages/agentic/desktop-test/src/wdio/nativeRunnerSupport.test.ts b/packages/agentic/desktop-test/src/wdio/nativeRunnerSupport.test.ts new file mode 100644 index 0000000000..97583e67f5 --- /dev/null +++ b/packages/agentic/desktop-test/src/wdio/nativeRunnerSupport.test.ts @@ -0,0 +1,113 @@ +const { nativeRunnerEnabled, nativeRunnerCaptureEnabled, nativeRunnerFixture, waitForNativeFixture } = require('./nativeRunnerSupport.cjs'); + +describe('native runner selection', () => { + const enabled = { FURN_NATIVE_DRIVER_TEST: '1', FURN_NATIVE_HANDOFF_APP_PATH: 'fixture' }; + + test.each(['darwin', 'win32'])('selects the opted-in native consumer on %s', (platform) => { + expect(nativeRunnerEnabled(platform, enabled)).toBe(true); + }); + + test('Windows uses an executable and native readiness, not the AppKit ready-file protocol', () => { + const application = 'D:\\fixtures with spaces\\furn-win32-fixture.exe'; + const fixture = nativeRunnerFixture('win32', application, 'exact-title', 'unused.ready'); + expect(fixture).toMatchObject({ + executable: application, + endpoint: 'win32', + application: { executablePath: application, windowTitle: 'exact-title' }, + args: ['--title', 'exact-title', '--offscreen', '1'], + }); + expect(() => nativeRunnerFixture('win32', 'D:\\Fixture.app', 'title', 'unused.ready')).toThrow(/fixture executable/); + }); + + test('preserves the macOS bundle executable and explicit ready-file protocol', () => { + expect(nativeRunnerFixture('darwin', '/fixtures/Fixture.app', 'title', '/runs/ready')).toMatchObject({ + executable: '/fixtures/Fixture.app/Contents/MacOS/fixture', + application: { bundleIdentifier: 'com.microsoft.furn.application-ownership-fixture', windowTitle: 'title' }, + args: ['--title', 'title', '--ready-file', '/runs/ready'], + }); + }); + + test('hosted input policy skips capture without skipping transport and ownership', () => { + const environment = { + FURN_NATIVE_DRIVER_TEST: '1', + FURN_NATIVE_HANDOFF_APP_PATH: 'fixture.exe', + FURN_DESKTOP_DRIVER_DISABLED_INPUT_FEATURES: 'keyboard,physicalClick,wheel', + }; + expect(nativeRunnerEnabled('win32', environment)).toBe(true); + expect(nativeRunnerCaptureEnabled('win32', environment)).toBe(false); + expect(nativeRunnerCaptureEnabled('win32', { ...environment, FURN_DESKTOP_DRIVER_DISABLED_INPUT_FEATURES: '' })).toBe(true); + }); + + test('native readiness waits for the exact window and ready control, then releases only its attached lease', async () => { + const lease = { processId: 123 }; + const onDisposed = jest.fn(); + const host = { + attach: jest.fn().mockRejectedValueOnce({ code: 'attach-failed' }).mockResolvedValue(lease), + windows: jest.fn().mockResolvedValue([{ id: 'window', title: 'fixture' }]), + find: jest + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValue([{ automationId: '1004', name: 'Ready' }]), + closeApplication: jest.fn(), + dispose: jest.fn(), + }; + await expect( + waitForNativeFixture({ + host, + target: { host: { application: { windowTitle: 'fixture' } } }, + fixture: { pid: 123, exitCode: null, signalCode: null }, + statusId: '1004', + onDisposed, + intervalMs: 1, + }), + ).resolves.toBe(lease); + expect(host.attach).toHaveBeenCalledTimes(2); + expect(host.find).toHaveBeenCalledTimes(2); + expect(host.closeApplication).toHaveBeenCalledWith(lease); + expect(host.dispose).toHaveBeenCalledTimes(1); + expect(onDisposed).toHaveBeenCalledTimes(1); + }); + + test('native readiness refuses a different process incarnation and still disposes its helper', async () => { + const host = { + attach: jest.fn().mockResolvedValue({ processId: 456 }), + closeApplication: jest.fn(), + dispose: jest.fn(), + }; + await expect( + waitForNativeFixture({ + host, + target: {}, + fixture: { pid: 123, exitCode: null, signalCode: null }, + statusId: '1004', + }), + ).rejects.toThrow(/exact fixture/); + expect(host.closeApplication).toHaveBeenCalledTimes(1); + expect(host.dispose).toHaveBeenCalledTimes(1); + }); + + test('native readiness cannot confirm cleanup when its helper disposal fails', async () => { + const onDisposed = jest.fn(); + await expect( + waitForNativeFixture({ + host: { + attach: jest.fn().mockRejectedValue(new Error('Readiness unavailable.')), + dispose: jest.fn().mockRejectedValue(new Error('Helper disposal unconfirmed.')), + }, + target: {}, + fixture: { pid: 123, exitCode: null, signalCode: null }, + statusId: '1004', + onDisposed, + }), + ).rejects.toThrow('Helper disposal unconfirmed.'); + expect(onDisposed).not.toHaveBeenCalled(); + }); + + test.each([ + ['linux', enabled], + ['win32', { FURN_NATIVE_DRIVER_TEST: '1' }], + ['win32', { FURN_NATIVE_HANDOFF_APP_PATH: 'fixture' }], + ])('does not launch a native consumer without supported OS and both opt-ins (%s)', (platform, environment) => { + expect(nativeRunnerEnabled(platform, environment)).toBe(false); + }); +}); From 088b8b6e9a0e375b43bc9877bc60bb98e3c585b5 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 12 Nov 2026 08:29:57 -0800 Subject: [PATCH 11/23] test(desktop): address runner lifecycle review Prove the historical borrowed-scope Ctrl+C self-wait, preserve optional macOS capture, bound native-case cleanup with exact-owner descriptor recovery, and require inner accessibility execution in Windows CI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3be5f341-831b-40d4-9582-337c8ea65709 --- .changeset/windows-native-runner-coverage.md | 2 +- .../actions/setup-desktop-driver/action.yml | 23 ++ .../fixtures/cli-interrupt/desktop.config.mjs | 122 +++++++---- .../desktop-test/references/contributing.md | 13 +- .../desktop-test/references/current-status.md | 105 ++++++++- .../desktop-test/references/lifecycle.md | 10 + .../references/native-qualification.md | 35 ++- .../desktop-test/src/contracts.contract.cjs | 53 +++-- .../desktop-test/src/contracts.test.ts | 1 + .../src/wdio/nativeRunner.contract.cjs | 201 +++++++++++++----- .../src/wdio/nativeRunner.contract.test.ts | 75 +++++-- .../src/wdio/nativeRunnerSupport.cjs | 149 ++++++++++++- .../src/wdio/nativeRunnerSupport.test.ts | 117 +++++++++- 13 files changed, 736 insertions(+), 170 deletions(-) diff --git a/.changeset/windows-native-runner-coverage.md b/.changeset/windows-native-runner-coverage.md index 900e8d8252..ecfda22829 100644 --- a/.changeset/windows-native-runner-coverage.md +++ b/.changeset/windows-native-runner-coverage.md @@ -2,4 +2,4 @@ '@fluentui-react-native/desktop-test': patch --- -Add Windows native private-descriptor MCP/WDIO consumer coverage for handoff, operator preemption, worker crash and lost runner-grant response, with explicit fixture readiness and hosted capability gates. Cover terminal agent-serve interruption, repeated signals and fenced failed cleanup without treating semantic SIGINT as physical console input. +Add Windows native private-descriptor MCP/WDIO consumer coverage for handoff, operator preemption, worker crash and lost runner-grant response, with explicit fixture readiness, bounded cleanup, optional capture and required accessibility-input execution in hosted CI. Cover terminal agent-serve interruption, the historical borrowed-scope self-wait, repeated signals and fenced failed cleanup without treating semantic SIGINT as physical console input. diff --git a/.github/actions/setup-desktop-driver/action.yml b/.github/actions/setup-desktop-driver/action.yml index 622e202ecd..917b3fbd58 100644 --- a/.github/actions/setup-desktop-driver/action.yml +++ b/.github/actions/setup-desktop-driver/action.yml @@ -241,3 +241,26 @@ runs: if ($ownership.Count -ne 4 -or @($ownership | Where-Object { $_.status -ne 'passed' }).Count -ne 0) { throw 'Native handoff, operator preemption, worker crash and lost-response recovery must all execute and pass.' } + $evidence = @( + Get-ChildItem -Path (Split-Path $results) -Directory -Filter 'native-*' | + ForEach-Object { + $record = Join-Path $_.FullName 'evidence.json' + if (Test-Path $record) { + Get-Content -Raw -Path $record | ConvertFrom-Json + } + } + ) + foreach ($mode in @('handoff', 'operator-preempt', 'lost-response')) { + $caseEvidence = @($evidence | Where-Object { $_.mode -eq $mode }) + if ( + $caseEvidence.Count -ne 1 -or + -not $caseEvidence[0].passed -or + -not $caseEvidence[0].cleanupConfirmed -or + $caseEvidence[0].wdio.executed -ne 2 -or + $caseEvidence[0].wdio.passed -ne 2 -or + $caseEvidence[0].wdio.skipped -ne 0 -or + $caseEvidence[0].wdio.accessibilityClick -ne 'passed' + ) { + throw "$mode must execute both inner WDIO ownership and accessibility-input specs; an inner skip is not qualification." + } + } diff --git a/packages/agentic/desktop-test/fixtures/cli-interrupt/desktop.config.mjs b/packages/agentic/desktop-test/fixtures/cli-interrupt/desktop.config.mjs index 465ce4c126..8e98a361d5 100644 --- a/packages/agentic/desktop-test/fixtures/cli-interrupt/desktop.config.mjs +++ b/packages/agentic/desktop-test/fixtures/cli-interrupt/desktop.config.mjs @@ -24,7 +24,8 @@ export default { name: 'interrupt-fixture', supportsAgent: true, validate() { - if (!['stop', 'repeat', 'child-first', 'failed-cleanup'].includes(scenario)) throw new Error('Unknown interrupt fixture scenario.'); + if (!['stop', 'repeat', 'child-first', 'failed-cleanup', 'borrowed-close'].includes(scenario)) + throw new Error('Unknown interrupt fixture scenario.'); }, async run({ supervisor, target, agent, onReady }) { const host = new FakeDesktopHost({ endpoint: 'win32' }); @@ -45,59 +46,92 @@ export default { const publications = new Map(); const records = new Map(); let service; - await supervisor.services.contribute( - [ - { - id: 'protected-driver', - ownership: 'owned', - prepare: ({ signal }) => ({ - identity: 'interrupt-fixture-driver', - async acquire() { - service = await startDesktopService(host, { - target: { endpoint: 'win32', id: target.id, platformName: 'windows', launchModes: ['attach'] }, - agent: { - ...agent, - async publish(connection) { - publications.set(connection.descriptor, connection); - const filename = path.join(root, connection.descriptor); - fs.writeFileSync(filename, 'published'); - return async () => { - fs.unlinkSync(filename); - record(`withdrawn:${connection.descriptor}`); - }; - }, + const resources = [ + { + id: 'protected-driver', + ownership: 'owned', + prepare: ({ signal }) => ({ + identity: 'interrupt-fixture-driver', + async acquire() { + service = await startDesktopService(host, { + target: { endpoint: 'win32', id: target.id, platformName: 'windows', launchModes: ['attach'] }, + agent: { + ...agent, + async publish(connection) { + publications.set(connection.descriptor, connection); + const filename = path.join(root, connection.descriptor); + fs.writeFileSync(filename, 'published'); + return async () => { + fs.unlinkSync(filename); + record(`withdrawn:${connection.descriptor}`); + }; }, - recovery: { - storage: { - read: async (name) => records.get(name), - publish: async (name, contents) => { - if (records.has(name)) throw new Error('Duplicate recovery record.'); - records.set(name, contents); - }, - remove: async (name, contents) => records.get(name) === contents && records.delete(name), + }, + recovery: { + storage: { + read: async (name) => records.get(name), + publish: async (name, contents) => { + if (records.has(name)) throw new Error('Duplicate recovery record.'); + records.set(name, contents); }, + remove: async (name, contents) => records.get(name) === contents && records.delete(name), }, - signal, - guard: (work) => supervisor.services.guard(work), - addSecret: (secret) => supervisor.reporter.addSecret(secret), - }); - const connection = publications.get(agent.descriptor); - const client = new DesktopDriverClient({ url: service.url, grant: connection.grant }); - await client.newSession({ alwaysMatch: { 'furn:target': target.id, 'furn:launchMode': 'attach' } }); - }, - dispose: () => service.close(), - }), + }, + signal, + guard: (work) => supervisor.services.guard(work), + addSecret: (secret) => supervisor.reporter.addSecret(secret), + }); + const connection = publications.get(agent.descriptor); + const client = new DesktopDriverClient({ url: service.url, grant: connection.grant }); + await client.newSession({ alwaysMatch: { 'furn:target': target.id, 'furn:launchMode': 'attach' } }); + }, + dispose: () => service.close(), + }), + }, + ]; + if (scenario === 'borrowed-close') { + const plan = resources[0].prepare({ signal: supervisor.services.controller.signal }); + await plan.acquire(); + let complete; + const completed = new Promise((resolve) => (complete = resolve)); + const start = supervisor.runner.start; + // Historical Storybook services were commands: they settled before the scope waited on its integration. + supervisor.runner.start = () => ({ + completed, + waitForReadiness: async (expected) => ({ ...expected, incarnation: 'interrupt-fixture-service', processId: process.pid }), + async stop() { + await plan.dispose(); + complete(0); }, - ], - { targetId: target.id, targetInstanceId: target.instanceId }, - ); + }); + try { + supervisor.services.start('protected-driver', { command: process.execPath, args: [], cwd: root, env: {} }); + } finally { + supervisor.runner.start = start; + } + await supervisor.services.readyProcess('protected-driver', { + tier: 'service-ready', + service: 'protected-driver', + descriptor: agent.descriptor, + }); + } else { + await supervisor.services.contribute(resources, { targetId: target.id, targetInstanceId: target.instanceId }); + } process.stdout.write(`${JSON.stringify({ type: 'ready', runId: supervisor.reporter.runId })}\n`); onReady(); setImmediate(() => { if (scenario === 'child-first') supervisor.services.fail(new Error('Injected required child exit before SIGINT.')); process.emit('SIGINT', 'SIGINT'); }); - await supervisor.services.guard(supervisor.services.waitForStop()); + try { + await supervisor.services.guard(supervisor.services.waitForStop()); + } finally { + if (scenario === 'borrowed-close') { + record('borrowed-close-start'); + await supervisor.services.close(); + record('borrowed-close-settled'); + } + } }, }, }; diff --git a/packages/agentic/desktop-test/references/contributing.md b/packages/agentic/desktop-test/references/contributing.md index a323f981c8..029cd46eea 100644 --- a/packages/agentic/desktop-test/references/contributing.md +++ b/packages/agentic/desktop-test/references/contributing.md @@ -51,7 +51,12 @@ The opt-in generic native MCP/WDIO consumer is `FURN_NATIVE_HANDOFF_APP_PATH` (signed AppKit bundle on macOS, plain Win32 fixture executable on Windows). Follow [native qualification](native-qualification.md#generic-attach-only-mcpwdio-handoff) for readiness, hosted capability gates, safe private storage and evidence. -`yarn test nativeRunnerSupport` checks host selection and readiness without -starting a native helper. `yarn test contracts -t agent-interrupt-cli` tests -semantic CLI interrupts with a protected fake-host service; it is not physical -console qualification. +`yarn test nativeRunnerSupport` checks host selection, readiness, optional +capture, internal deadlines, exact-owner descriptor cleanup and required inner +input execution without starting a native helper. +`yarn test contracts -t agent-interrupt` tests semantic CLI interrupts with a +protected fake-host service, including the historical borrowed-scope self-wait; +it is not physical console qualification. For historical comparisons only, +`FURN_INTERRUPT_PACKAGE_ROOT` selects an isolated built package copy for +`agent-interrupt-borrowed-cli`. Keep historical source overrides out of the +working tree and pass fixture values as arguments/environment, never spliced code. diff --git a/packages/agentic/desktop-test/references/current-status.md b/packages/agentic/desktop-test/references/current-status.md index 4ed8766df0..7c0c3102ef 100644 --- a/packages/agentic/desktop-test/references/current-status.md +++ b/packages/agentic/desktop-test/references/current-status.md @@ -1254,8 +1254,9 @@ these evidence-only markdown edits. ## Windows Ctrl+C regression and native consumer selection — 2026-11-12 UTC -Source: review candidate based on `04d8d8b13177727492ef204cf190840fa8a3f2a1` -for #4491, with the test/CI/documentation changes described here. This is +Source: review follow-up to `0ae47d15193770f7455db306b1e4297de76b4eaf`, +based on `04d8d8b13177727492ef204cf190840fa8a3f2a1` for #4491, with +the test/CI/documentation changes described here. This is portable implementation evidence, not a new native qualification. No native fixture/helper/app was launched and no physical-input campaign was acquired. @@ -1274,11 +1275,37 @@ descriptor removal and exact workload-claim disposition: These are in-process `process.emit('SIGINT', 'SIGINT')` dispatches in the CLI process, not `GenerateConsoleCtrlEvent`, physical SendInput, or a Windows -console broadcast. The historical self-wait repair is already present from -#4478 (`586e4c38545ca3eedccd1c9c817f2f89ce16219d`); no additional runtime -patch is justified by these results. Physical console Ctrl+C on the Windows -Fabric and Win32 Paper `serve --agent` paths remains **Not run** for this -candidate pending review and the exclusive desktop grant. +console broadcast. These four cases do not themselves reproduce the historical +borrowed-scope self-wait and are not proof of its repair. The borrowed-scope +regression in +`packages\agentic\storybook-desktop\src\cli\DesktopStorybookCli.test.ts:836–861` +checks that current integrations leave the scope to its owner. + +**Observed historical red/green proof:** the added +`agent-interrupt-borrowed-cli` contract deliberately awaits the borrowed +scope's `close()` inside the integration after a single semantic SIGINT, +with a command-style protected fake-host service that settles first. +An isolated copy of the current built package, overriding only `cli.ts` +and `services.ts` with the exact source at +`f14e2542d7980c9fc6edfc419ddff6067c573ed6` (`586e4c385^`, before #4478), +**Fails as expected** after the 15-second subprocess deadline: + +- the exact child/claim owner PID matches; +- its one owned resource is settled, but the claim remains `active`; +- `run.json` is `incomplete`, `terminal: false`; +- borrowed close starts and never settles. + +The same contract on this branch **Passes**: exit 0, terminal `passed`, +one `run-finished`, both descriptors withdrawn, borrowed close settled and +claim released. Retained `review-fixes/historical-source.json` identifies both +historical blobs and source hashes; `historical-red.json`/`.log` and +`historical-green.json`/`.log` record the exact comparison. Historical TypeScript +was erased with the installed Babel TypeScript plugin in that scratch copy, +not checked into the working tree. This confirms the owner-side repair already +present from #4478 (`586e4c38545ca3eedccd1c9c817f2f89ce16219d`); no new runtime +patch is needed for this composition. Physical console Ctrl+C on the Windows +Fabric and Win32 Paper `serve --agent` paths remains **Not run** pending review +and the exclusive desktop grant. **Observed selection regression:** the extracted original native consumer gate failed the Windows opt-in test (`Expected true; Received false`; four @@ -1296,15 +1323,27 @@ deletion before reuse, exact external-process incarnation, stale resources, terminal reports, descriptor withdrawal and claim release. A cleanup-confirmed failure permits the next case; uncertain cleanup prevents further launches. Windows records use the validated default private store with unique names, -not unsafe project ancestry or repaired ACLs. +not unsafe project ancestry or repaired ACLs. Review corrections add a +120-second internal abort deadline, 30-second bounded shutdown and a +135-second Windows fixture watchdog. Passing processes must exit naturally; +an unref'd five-second post-shutdown watchdog fails lingering handles. +The parent knows each case's root and removes only its exited child's exact +descriptor records through private storage with PID/content checks; it never +releases claims or treats descriptor removal as proof of native cleanup. Both accessible Windows and Win32 Storybook PR jobs now explicitly provision the fixture and select the generic consumer through `run-native-runner-contract: 'true'`. The action requires all four ownership -cases to execute and pass; an all-skipped result is rejected. Capture is a -separate capability-gated case, skipped under hosted disabled-input policy; +cases to execute and pass; an all-skipped result is rejected. Both inner WDIO +specs must also execute/pass outside the deliberate worker crash, and the +action verifies `evidence.wdio` counts and accessibility success for the three +non-crash modes. A silently skipped inner input spec is not qualification. +Capture is a separate capability-gated case, skipped under hosted disabled-input policy; core transport/ownership still executes without physical input. The plain fixture is not React Native renderer or Storybook-handoff qualification. +The disabled-input capture gate now applies equally to macOS and Windows. +Endpoint refusal records `capture: "unsupported"` plus `captureSkipped: true`; +ownership/cleanup can still pass without a capture pass. Implementation checks and retained review artifacts are under `artifacts\desktop\f11\w1-fix`: @@ -1329,3 +1368,49 @@ Implementation checks and retained review artifacts are under **Not run**, reserved for final locked validation after review. Patch changeset: `.changeset/windows-native-runner-coverage.md`. + +## Windows review corrections — 2026-11-12 UTC + +Source: `0ae47d15193770f7455db306b1e4297de76b4eaf` plus the review-fix +working-tree changes, not a native-qualified commit. Endpoint/session kind: +not applicable to portable checks; no native run IDs, desktop campaign, +native helper/fixture launch or physical input. The original issue's physical +Windows Fabric / Win32 Paper console cases remain **Not run** for this +candidate pending review and the W1 grant. + +The historical comparison and review-regression red/green receipts are under +`artifacts\desktop\f11\w1-fix\review-fixes`. The new portable support regressions +first **Failed** 8 / **Passed** 10 (`support-red.json`), including the macOS +policy regression and missing Windows fixture lifetime. After correction, +`yarn workspace @fluentui-react-native/desktop-test test nativeRunnerSupport +--runInBand` **Passed** 18 (`support-green.json`). These do not launch native +processes or qualify the new Windows consumer. + +| Review regression | Observed portable result | Receipt | +| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| Borrowed-scope self-close after command drain | Expected **Failed** on exact pre-#4478 CLI/services: 15-second timeout, active claim, settled resource, nonterminal `incomplete`, borrowed close still pending. Same byte-identical fixture **Passed** on the branch. Historical run: `8b6be099-3f0b-4551-9905-1a40b5104197`. | `historical-source.json`, `historical-red.json`/`.log`, `historical-green.json`/`.log` | +| Optional capture | **Passed**: both host policies skip disabled capture; endpoint refusal is explicitly unsupported/skipped without failing ownership. macOS bundle/ready-file arguments are unchanged. | `support-red.json`, `focused-green.json` | +| Deadline and failed-case records | **Passed**: ignored work still reaches bounded shutdown; hung shutdown is bounded; Windows lifetime is 135 seconds; cleanup preserves foreign/replaced records and uses exact contents for removal. Real private-store fallback remains **Not run**. | `focused-green.json` | +| Required inner input | **Passed**: one executed ownership spec cannot qualify the consumer. The actual old action guard incorrectly accepts that fixture; the new guard rejects it and accepts two executed/passed specs. No native step was executed for this comparison. | `ci-input-red-green.json`, `focused-green.json` | + +Final portable validation from `D:\dev\furn-w1`: + +- `yarn workspace @fluentui-react-native/desktop-test test contracts nativeRunner +--runInBand --json --outputFile +D:\dev\furn-w1\artifacts\desktop\f11\w1-fix\review-fixes\focused-green.json`: + **Passed** 50, **Skipped** 5, failed zero. +- `yarn workspace @fluentui-react-native/desktop-test test --runInBand --json +--outputFile D:\dev\furn-w1\artifacts\desktop\f11\w1-fix\review-fixes\portable-test.json`: + **Passed** 396, **Skipped** 12, failed zero. Native opt-ins were unset; + test scratch and default claims stayed under isolated project artifacts. +- Package `yarn format`, `yarn lint`, `yarn build`: **Passed**. Root + `yarn format:check`, `yarn check-agent-instructions`, and `git diff --check`: + **Passed**. +- `yarn markdown-link-check -c .github\markdown-link-check-config.json ` + for the four edited references and patch changeset: **Passed** (20 links). + Both action/workflow YAML files and all eight actual embedded PowerShell + blocks: **Passed** syntax-only validation (`yaml-syntax/summary.json`). +- Native consumer modes, optional native capture, physical console Ctrl+C, + hosted CI and locked clean-cache `buildci`: **Not run**, reserved for the + coordinator's post-review W1 grant and final validation. Post-commit + changeset checking is recorded in the review handoff receipt. diff --git a/packages/agentic/desktop-test/references/lifecycle.md b/packages/agentic/desktop-test/references/lifecycle.md index 804563d8c0..d8cf5dc005 100644 --- a/packages/agentic/desktop-test/references/lifecycle.md +++ b/packages/agentic/desktop-test/references/lifecycle.md @@ -80,6 +80,16 @@ console, so child exit and parent interruption can race. In-process semantic regression coverage; `child.kill('SIGINT')` on Windows terminates the child and is not graceful-console or physical-input qualification. +The `agent-interrupt-borrowed-cli` contract deliberately recreates the retired +composition: an integration awaits its borrowed scope's `close()` after stop, +while the historical CLI guards that integration in the same scope. The +command resource settles first; an old CLI then hangs with an active claim +and nonterminal report. Current integrations must still leave borrowed scopes +to their owner, as checked by the borrowed-scope regression in +`packages\agentic\storybook-desktop\src\cli\DesktopStorybookCli.test.ts:836–861`. +The CLI subprocess regression checks the separate owner-side repair without +claiming a physical Windows console broadcast. + The generated-descriptor service bridge allows 60 seconds per command shutdown phase so a nested scope can use its 45-second disposal budget and flush reporting and stdio before the bridge exits. A missed deadline or failed terminal handshake diff --git a/packages/agentic/desktop-test/references/native-qualification.md b/packages/agentic/desktop-test/references/native-qualification.md index 96ddf5b1d2..d9b343866a 100644 --- a/packages/agentic/desktop-test/references/native-qualification.md +++ b/packages/agentic/desktop-test/references/native-qualification.md @@ -202,6 +202,17 @@ withdrawal, claim release, and preservation of the same external process incarnation. A failed mode does not suppress later modes when its cleanup is confirmed; unconfirmed cleanup blocks subsequent launches. +Each subprocess has a 120-second internal `AbortSignal.timeout` case budget +followed by at most 30 seconds of shutdown, below the parent's 180-second +hard limit. Cancellation stops the supervisors and reaches readiness, MCP and +runner-grant waits. Shutdown also waits for readiness-helper and WDIO drain. +Failed cases exit after recording their cleanup result; successful cases must +exit naturally, with an unref'd five-second watchdog failing any lingering +event-loop handles. After a failed child has exited, the parent removes only +its uniquely named descriptors through the platform private-storage API, +checking the exited PID and exact record contents. This does not release a +claim or establish native cleanup; uncertainty still blocks the next launch. + ### Windows fixture and hosted selection The same contract runs on Windows against the existing plain Win32 fixture, @@ -221,22 +232,30 @@ yarn workspace @fluentui-react-native/desktop-test test nativeRunner.contract -- Windows resolves the canonical executable and unique exact title, waits through the supported native attach/window/find APIs for that process's `1004` status control to say `Ready`, then releases the readiness lease without stopping the -external app. No new native ready-file option is required. Core modes use an -offscreen window and accessibility input only; a read-only ownership case still -executes when an input assertion is unsupported. The optional capture mode +external app. No new native ready-file option is required. The Windows fixture +receives `--lifetime-ms 135000`, slightly beyond the case budget, rather than +its ten-minute default. Core modes use an offscreen window and accessibility +input only. Except for the deliberate worker crash, both the ownership and +accessibility-activation specs must execute and pass; an unsupported/skipped +input spec cannot qualify the native consumer. `evidence.wdio` records their +counts and explicit accessibility result. The optional capture mode uses an onscreen window and is skipped when `FURN_DESKTOP_DRIVER_DISABLED_INPUT_FEATURES` disables any input feature, matching -the Windows native-contract gate. Capture/resource-link refusal remains -unsupported, never an input or ownership pass. No mode claims physical input. +the Windows native-contract gate. The same disabled-input policy gates macOS +capture without changing its bundle/ready-file path. Endpoint refusal records +`capture: "unsupported"` and `captureSkipped: true`; ownership/cleanup may +still pass, but no capture pass is claimed. No mode claims physical input. Windows descriptors use unique names in the validated default private store. Project artifact ancestry is not repaired or trusted for private storage. The `setup-desktop-driver` action's `run-native-runner-contract: 'true'` explicitly provisions the fixture and selects this consumer after helper diagnosis. Both accessible Windows and Win32 Storybook PR jobs enable it. -Their selection guard requires all four core modes to execute and pass; -an all-skipped native consumer cannot make this check green. Hosted capture -remains an explicit skipped test. +Their selection guard requires all four core modes to execute and pass and +checks the three non-crash evidence records for two executed/passed inner +WDIO specs, zero skips and successful accessibility input. Neither an +all-skipped consumer nor a silently skipped inner input spec makes this check +green. Hosted capture remains an explicit skipped test. Retain `artifacts/desktop/f11/native-*/evidence.json` and the run reports. Do not copy the private descriptor directory into shared artifacts. Record source diff --git a/packages/agentic/desktop-test/src/contracts.contract.cjs b/packages/agentic/desktop-test/src/contracts.contract.cjs index 4b923bfc0e..3f51848b02 100644 --- a/packages/agentic/desktop-test/src/contracts.contract.cjs +++ b/packages/agentic/desktop-test/src/contracts.contract.cjs @@ -1894,8 +1894,12 @@ async function main() { logs.some((log) => log.includes('serve --agent requires an integration that supports agents; \\"fixture\\" does not.')), logs.join('\n'), ); - } else if (mode === 'agent-interrupt-cli') { - for (const scenario of ['stop', 'repeat', 'child-first', 'failed-cleanup']) { + } else if (mode === 'agent-interrupt-cli' || mode === 'agent-interrupt-borrowed-cli') { + const interruptPackageRoot = + mode === 'agent-interrupt-borrowed-cli' ? (process.env.FURN_INTERRUPT_PACKAGE_ROOT ?? packageRoot) : packageRoot; + for (const scenario of mode === 'agent-interrupt-borrowed-cli' + ? ['borrowed-close'] + : ['stop', 'repeat', 'child-first', 'failed-cleanup']) { const directory = path.join(root, scenario); const home = path.join(directory, 'home'); fs.mkdirSync(home, { recursive: true }); @@ -1903,12 +1907,19 @@ async function main() { for (const name of ['FURN_DESKTOP_REPORT_CONTEXT', 'FURN_DESKTOP_PROCESS_CONTROL', 'DESKTOP_REPORTER', 'DESKTOP_EVENTS']) { delete env[name]; } + const claims = + process.platform === 'win32' + ? path.join(home, 'Microsoft', 'FluentUIReactNative', 'desktop-test', 'workload') + : process.platform === 'darwin' + ? path.join(home, 'Library', 'Caches', 'com.microsoft.fluentui-react-native.desktop-test', 'workload') + : path.join(home, '.cache', 'fluentui-react-native', 'desktop-test', 'workload'); + const claim = path.join(claims, 'interactive.lock'); const child = spawnSync( process.execPath, [ - path.join(packageRoot, 'config', 'cli.cjs'), + path.join(interruptPackageRoot, 'config', 'cli.cjs'), '--config', - path.join(packageRoot, 'fixtures', 'cli-interrupt', 'desktop.config.mjs'), + path.join(interruptPackageRoot, 'fixtures', 'cli-interrupt', 'desktop.config.mjs'), '--target', 'win32', '--reporter', @@ -1920,7 +1931,31 @@ async function main() { ], { cwd: directory, env, encoding: 'utf8', timeout: 15_000, windowsHide: true }, ); - assert.ifError(child.error); + if (child.error) { + const readiness = JSON.parse(child.stdout.trim() || '{}'); + const resultPath = path.join(directory, 'reports', readiness.runId ?? 'not-ready', 'run.json'); + const result = fs.existsSync(resultPath) ? JSON.parse(fs.readFileSync(resultPath, 'utf8')) : {}; + const ownership = fs.existsSync(claim) ? JSON.parse(fs.readFileSync(claim, 'utf8')) : {}; + const disposalPath = path.join(directory, 'disposal.json'); + const disposal = fs.existsSync(disposalPath) ? JSON.parse(fs.readFileSync(disposalPath, 'utf8')) : []; + throw new Error( + `${scenario}: ${JSON.stringify({ + deadline: 'code' in child.error ? child.error.code : child.error.message, + ownerPid: child.pid, + runId: readiness.runId, + claimOwnerPid: ownership.owner?.processId, + claim: ownership.state, + terminal: result.terminal, + status: result.status, + resourceCount: ownership.resources?.length, + ownedResourcesSettled: ownership.resources?.every( + (resource) => resource.ownership !== 'owned' || resource.disposal === 'settled', + ), + borrowedCloseStarted: disposal.includes('borrowed-close-start'), + borrowedCloseSettled: disposal.includes('borrowed-close-settled'), + })}`, + ); + } assert.equal(child.signal, null, `${scenario}: ${child.stderr}`); const failed = scenario === 'child-first' || scenario === 'failed-cleanup'; assert.equal(child.status, failed ? 1 : 0, `${scenario}: ${child.stderr}\n${child.stdout}`); @@ -1942,13 +1977,7 @@ async function main() { assert(disposal.includes('withdrawn:runner-connection.json')); assert(disposal.indexOf('withdrawn:interrupt-agent.json') < disposal.indexOf('drain-start')); assert(disposal.includes('host-disposed')); - const claims = - process.platform === 'win32' - ? path.join(home, 'Microsoft', 'FluentUIReactNative', 'desktop-test', 'workload') - : process.platform === 'darwin' - ? path.join(home, 'Library', 'Caches', 'com.microsoft.fluentui-react-native.desktop-test', 'workload') - : path.join(home, '.cache', 'fluentui-react-native', 'desktop-test', 'workload'); - const claim = path.join(claims, 'interactive.lock'); + if (scenario === 'borrowed-close') assert(disposal.includes('borrowed-close-settled')); if (scenario === 'failed-cleanup') { const ownership = JSON.parse(fs.readFileSync(claim, 'utf8')); assert.equal(ownership.state, 'fenced'); diff --git a/packages/agentic/desktop-test/src/contracts.test.ts b/packages/agentic/desktop-test/src/contracts.test.ts index 02d1e89452..d649c666a0 100644 --- a/packages/agentic/desktop-test/src/contracts.test.ts +++ b/packages/agentic/desktop-test/src/contracts.test.ts @@ -16,6 +16,7 @@ describe('neutral desktop harness contracts', () => { 'lifecycle', 'interrupt-cli', 'agent-interrupt-cli', + 'agent-interrupt-borrowed-cli', 'resources', 'owned-service', 'agent', diff --git a/packages/agentic/desktop-test/src/wdio/nativeRunner.contract.cjs b/packages/agentic/desktop-test/src/wdio/nativeRunner.contract.cjs index 060d2e6b79..31d987d2ed 100644 --- a/packages/agentic/desktop-test/src/wdio/nativeRunner.contract.cjs +++ b/packages/agentic/desktop-test/src/wdio/nativeRunner.contract.cjs @@ -6,12 +6,25 @@ const { createRequire } = require('node:module'); const path = require('node:path'); const { setTimeout: delay } = require('node:timers/promises'); const { pathToFileURL } = require('node:url'); -const { nativeRunnerCaptureEnabled, nativeRunnerEnabled, nativeRunnerFixture, waitForNativeFixture } = require('./nativeRunnerSupport.cjs'); +const { + nativeRunnerCaptureEnabled, + nativeRunnerCapture, + nativeRunnerEnabled, + nativeRunnerFixture, + waitForNativeFixture, + nativeRunnerRecords, + removeNativeRunnerDescriptors, + nativeRunnerExecution, + runNativeRunnerCase, +} = require('./nativeRunnerSupport.cjs'); async function main() { const mode = process.argv[2]; assert(nativeRunnerEnabled(process.platform, process.env), 'Native runner contracts require the supported host and explicit opt-ins.'); - assert(['handoff', 'operator-preempt', 'worker-crash', 'lost-response', 'capture'].includes(mode), 'Unknown native runner scenario.'); + assert( + ['handoff', 'operator-preempt', 'worker-crash', 'lost-response', 'capture', 'cleanup'].includes(mode), + 'Unknown native runner scenario.', + ); if (mode === 'capture') assert(nativeRunnerCaptureEnabled(process.platform, process.env), 'Capture is unavailable under this host policy.'); const packageRoot = path.resolve(__dirname, '../..'); @@ -25,15 +38,36 @@ async function main() { const { connectDesktopRunner, createDesktopRunnerBroker } = await import(lib('wdio/runnerConnection.js')); const { resolveDesktopScopeDomains } = await import('@fluentui-react-native/desktop-driver/authoring'); const { NativeDesktopHost, resolveNativeDesktopDriver } = await import('@fluentui-react-native/desktop-driver'); - const { createMacOSApplicationProbe, createWindowsApplicationProbe, createWindowsPrivateStorage } = + const { createMacOSApplicationProbe, createMacOSPrivateStorage, createWindowsApplicationProbe, createWindowsPrivateStorage } = await import('@fluentui-react-native/desktop-driver/authority'); + if (mode === 'cleanup') { + const root = process.argv[3]; + const parent = path.join(repositoryRoot, 'artifacts', 'desktop', 'f11'); + assert.equal(path.dirname(root), parent, 'cleanup must name a parent-owned native case root'); + assert(/^native-(handoff|operator-preempt|worker-crash|lost-response|capture)-/.test(path.basename(root))); + const ownerPid = Number(process.argv[4]); + assert(Number.isSafeInteger(ownerPid) && ownerPid > 0, 'cleanup requires the exited child PID'); + const { privateRoot, agentDescriptor, runnerDescriptor } = nativeRunnerRecords(process.platform, root); + const artifact = await resolveNativeDesktopDriver({ + platform: process.platform === 'win32' ? 'windows' : 'macos', + buildPolicy: 'never', + }); + const storage = + process.platform === 'win32' ? await createWindowsPrivateStorage({ artifact }) : await createMacOSPrivateStorage(privateRoot); + const result = await removeNativeRunnerDescriptors(storage, [agentDescriptor, runnerDescriptor], ownerPid); + process.stdout.write(JSON.stringify(result)); + if (result.retained.length) process.exitCode = 1; + return; + } const { connectWithDescriptor } = await import(pathToFileURL(path.join(mcpRoot, 'lib/connection.js')).href); const { createDesktopDriverMcpServer } = await import(pathToFileURL(path.join(mcpRoot, 'lib/server.js')).href); const { Client } = await import(pathToFileURL(mcpRequire.resolve('@modelcontextprotocol/sdk/client/index.js')).href); const { InMemoryTransport } = await import(pathToFileURL(mcpRequire.resolve('@modelcontextprotocol/sdk/inMemory.js')).href); const parent = path.join(repositoryRoot, 'artifacts', 'desktop', 'f11'); fs.mkdirSync(parent, { recursive: true, mode: 0o700 }); - const root = fs.mkdtempSync(path.join(parent, `native-${mode}-`)); + const root = process.argv[3] ?? fs.mkdtempSync(path.join(parent, `native-${mode}-`)); + assert.equal(path.dirname(root), parent, 'native case artifacts must stay in the dedicated contract directory'); + assert(path.basename(root).startsWith(`native-${mode}-`), 'the parent must provide a case-specific artifact root'); const evidence = { mode, passed: false, @@ -50,9 +84,7 @@ async function main() { // Windows validates every storage ancestor; project scratch need not be owner-only. Use unique records in its // validated default store, not an ACL repair or a copy of the descriptors into artifacts. const windows = process.platform === 'win32'; - const privateRoot = windows ? undefined : path.join(root, 'private'); - const agentDescriptor = windows ? `${path.basename(root)}-agent.json` : 'agent-connection.json'; - const runnerDescriptor = windows ? `${path.basename(root)}-runner.json` : 'runner-connection.json'; + const { privateRoot, agentDescriptor, runnerDescriptor } = nativeRunnerRecords(process.platform, root); const locks = path.join(root, 'locks'); const ready = path.join(root, 'fixture.ready'); const fixturePlan = nativeRunnerFixture( @@ -69,8 +101,7 @@ async function main() { const run = reporting('tests'); evidence.fixture = fixturePlan.application; evidence.helper = { artifactId: artifact.artifactId, sourceDigest: artifact.sourceDigest, provider: artifact.provider }; - const fixture = spawn(fixturePlan.executable, fixturePlan.args, { stdio: 'ignore', windowsHide: true }); - fixture.once('error', () => undefined); + let fixture; const target = { endpoint: fixturePlan.endpoint, host: { @@ -98,11 +129,26 @@ async function main() { let cleanup; let incarnation; let completed; + let readinessWork; + let runFinished = false; let readinessDisposed = !windows; const shutdown = () => (cleanup ??= (async () => { const failures = []; - for (const close of [async () => client?.close(), async () => server?.close(), () => runner.close(), () => service.close()]) { + for (const close of [ + async () => { + try { + await readinessWork; + } catch (error) { + if (!readinessDisposed) throw error; + } + }, + async () => client?.close(), + async () => server?.close(), + () => runner.close(), + async () => completed, + () => service.close(), + ]) { try { await close(); } catch (error) { @@ -111,6 +157,7 @@ async function main() { } try { evidence.fixturePreserved = + fixture !== undefined && fixture.exitCode === null && fixture.signalCode === null && (incarnation === undefined || (await probeApplication(incarnation)) === 'running'); @@ -121,7 +168,7 @@ async function main() { failures.push(error); } evidence.claimReleased = !fs.existsSync(path.join(locks, 'interactive.lock')); - if (fixture.pid !== undefined && fixture.exitCode === null && fixture.signalCode === null) { + if (fixture?.pid !== undefined && fixture.exitCode === null && fixture.signalCode === null) { try { const exited = once(fixture, 'exit', { signal: AbortSignal.timeout(5_000) }); fixture.kill('SIGTERM'); @@ -130,7 +177,7 @@ async function main() { failures.push(error); } } - evidence.fixtureExited = fixture.pid === undefined || fixture.exitCode !== null || fixture.signalCode !== null; + evidence.fixtureExited = fixture?.pid === undefined || fixture.exitCode !== null || fixture.signalCode !== null; if (incarnation && evidence.fixtureExited) { try { assert.equal(await probeApplication(incarnation), 'exited'); @@ -140,8 +187,9 @@ async function main() { } for (const report of [run, serviceReport]) { try { - if (failures.length) report.fail(new Error('Native fixture cleanup was not confirmed.')); + if (failures.length && (report !== run || !runFinished)) report.fail(new Error('Native fixture cleanup was not confirmed.')); const result = await report.finish(); + if (report === run) runFinished = true; assert.equal(result.terminal, true); evidence[report === run ? 'run' : 'serviceRun'] = { id: result.runId, status: result.status, counts: result.counts }; } catch (error) { @@ -154,21 +202,24 @@ async function main() { fs.writeFileSync(path.join(root, 'evidence.json'), JSON.stringify(evidence, null, 2), { mode: 0o600 }); if (failures.length) throw new AggregateError(failures, 'Native handoff fixture cleanup failed.'); })()); - process.once('SIGTERM', () => void shutdown().finally(() => process.exit(143))); - try { + async function execute(signal) { + fixture = spawn(fixturePlan.executable, fixturePlan.args, { stdio: 'ignore', windowsHide: true }); + fixture.once('error', () => undefined); if (windows) { - incarnation = await waitForNativeFixture({ + readinessWork = waitForNativeFixture({ host: new NativeDesktopHost({ artifact, application: fixturePlan.application, endpoint: fixturePlan.endpoint }), target, fixture, statusId: fixturePlan.statusId, + signal, onDisposed: () => { readinessDisposed = true; }, }); + incarnation = await readinessWork; evidence.readiness = 'native exact executable/title/PID and Ready control'; } else { - await waitFor(ready, fixture); + await waitFor(ready, fixture, signal); assert.equal(Number(fs.readFileSync(ready, 'utf8')), fixture.pid); evidence.readiness = 'fixture ready-file/PID'; } @@ -184,7 +235,9 @@ async function main() { client = new Client({ name: 'native-handoff-contract', version: '1.0.0' }); await client.connect(clientTransport); const call = async (name, args = {}) => { - const result = await client.callTool({ name, arguments: args }); + signal.throwIfAborted(); + const result = await client.callTool({ name, arguments: args }, undefined, { signal }); + signal.throwIfAborted(); modelResults.push(result); return result; }; @@ -211,19 +264,28 @@ async function main() { const stale = found.matches[0].ref; let captured; if (!windows || mode === 'capture') { - const capture = await call('desktop_capture'); - captured = capture.content.find(({ type }) => type === 'resource_link'); - evidence.capture = captured ? 'passed' : 'unsupported'; - if (mode === 'capture') assert(captured, 'the selected capture contract must produce its resource link'); + const capture = await nativeRunnerCapture({ + enabled: nativeRunnerCaptureEnabled(process.platform, process.env), + capture: () => call('desktop_capture'), + }); + captured = capture.artifact; + evidence.capture = capture.status; + if (capture.skipped) { + evidence.captureSkipped = true; + evidence.captureSkipReason = capture.reason; + } } else { evidence.capture = nativeRunnerCaptureEnabled(process.platform, process.env) ? 'not run (separate capture case)' : 'skipped (host input policy)'; } await runner.open(false); - await assert.rejects(createDesktopRunnerBroker({ connection, targetId: target.id, addSecret: (secret) => run.addSecret(secret) }), { - code: 'session not created', - }); + await assert.rejects( + createDesktopRunnerBroker({ connection, targetId: target.id, addSecret: (secret) => run.addSecret(secret), signal }), + { + code: 'session not created', + }, + ); evidence.defaultBusy = true; if (mode !== 'operator-preempt') { assert.deepEqual(json(await call('desktop_session', { action: 'relinquish' })), { relinquished: true }); @@ -245,9 +307,12 @@ async function main() { return response; }; try { - await assert.rejects(createDesktopRunnerBroker({ connection, targetId: target.id, addSecret: (secret) => run.addSecret(secret) }), { - message: 'Injected runner acquisition response loss.', - }); + await assert.rejects( + createDesktopRunnerBroker({ connection, targetId: target.id, addSecret: (secret) => run.addSecret(secret), signal }), + { + message: 'Injected runner acquisition response loss.', + }, + ); } finally { globalThis.fetch = fetcher; } @@ -281,7 +346,7 @@ async function main() { (error) => error, ); if (mode !== 'worker-crash') { - await waitFor(path.join(root, 'worker.ready'), fixture); + await waitFor(path.join(root, 'worker.ready'), fixture, signal); if (mode === 'operator-preempt') { const old = await call('desktop_check', { ref: stale, expect: { enabled: true } }); assert(old.isError); @@ -319,7 +384,7 @@ async function main() { assert.equal(JSON.parse(page.content[0].text).reason, 'observation-cursor-expired'); const acted = json(await call('desktop_act', { steps: [{ act: { action: 'activate', ref: stale, sample: found.sample.id } }] })); assert.equal(acted.result.status, 'stopped'); - if (captured) await assert.rejects(client.readResource({ uri: captured.uri })); + if (captured) await assert.rejects(client.readResource({ uri: captured.uri }, { signal })); evidence.staleResourcesRejected = true; json(await call('desktop_session', { action: 'close' })); await runner.close(); @@ -328,50 +393,72 @@ async function main() { assert(!grantPattern.test(JSON.stringify(modelResults))); assert(!grantPattern.test(output)); const result = await run.finish(); + runFinished = true; assert.equal(result.terminal, true); assert.equal(result.status, mode === 'worker-crash' ? 'failed' : 'passed'); - if (mode !== 'worker-crash') assert(result.counts.executed > 0, 'ownership must execute even when input is unsupported'); + evidence.wdio = nativeRunnerExecution(result.counts, mode); for (const report of [run, serviceReport]) { for (const entry of fs.readdirSync(report.root, { recursive: true, withFileTypes: true })) { if (entry.isFile()) assert(!grantPattern.test(fs.readFileSync(path.join(entry.parentPath, entry.name), 'utf8'))); } } evidence.credentialsConfined = true; - } catch (error) { - const message = (error instanceof Error ? error.message : 'Native handoff fixture failed.').replace( - /fdg1\.[0-9a-f]{32}\.[A-Za-z0-9_-]{43}/g, - '[redacted]', - ); - evidence.error = message; - run.fail(error); - } finally { - try { - await shutdown(); - await completed; - } catch { - evidence.error ??= 'Native fixture cleanup failed.'; - } } + const stop = new AbortController(); + const terminate = () => stop.abort(new Error('Native runner case interrupted.')); + process.once('SIGTERM', terminate); + const deadline = await runNativeRunnerCase({ + signal: stop.signal, + shutdown, + work: execute, + onAbort(reason) { + runner.services.fail(reason); + service.services.fail(reason); + }, + onError(error) { + evidence.error ??= (error instanceof Error ? error.message : 'Native handoff fixture failed.').replace( + /fdg1\.[0-9a-f]{32}\.[A-Za-z0-9_-]{43}/g, + '[redacted]', + ); + if (!runFinished) run.fail(error); + }, + }); + process.removeListener('SIGTERM', terminate); + evidence.deadline = deadline; + if (deadline.cleanupTimedOut) evidence.cleanupConfirmed = false; evidence.passed = !evidence.error && evidence.fixturePreserved && evidence.cleanupConfirmed; fs.writeFileSync(path.join(root, 'evidence.json'), JSON.stringify(evidence, null, 2), { mode: 0o600 }); - process.stdout.write( - JSON.stringify({ - mode, - passed: evidence.passed, - cleanupConfirmed: evidence.cleanupConfirmed, - evidence: path.join(root, 'evidence.json'), - }), + await new Promise((resolve) => + process.stdout.write( + JSON.stringify({ + mode, + passed: evidence.passed, + cleanupConfirmed: evidence.cleanupConfirmed, + capture: evidence.capture, + captureSkipped: evidence.captureSkipped, + captureSkipReason: evidence.captureSkipReason, + evidence: path.join(root, 'evidence.json'), + }), + resolve, + ), ); - if (!evidence.passed) process.exitCode = 1; + if (!evidence.passed) process.exit(1); + setTimeout(() => { + evidence.error = 'Native contract process remained alive after shutdown.'; + evidence.passed = false; + evidence.cleanupConfirmed = false; + fs.writeFileSync(path.join(root, 'evidence.json'), JSON.stringify(evidence, null, 2), { mode: 0o600 }); + process.exit(1); + }, 5_000).unref(); } -async function waitFor(file, fixture) { - const deadline = Date.now() + 60_000; +async function waitFor(file, fixture, signal) { + const deadline = AbortSignal.any([signal, AbortSignal.timeout(60_000)]); while (!fs.existsSync(file)) { if (fixture.pid === undefined || fixture.exitCode !== null || fixture.signalCode !== null) throw new Error('The native application fixture exited before readiness.'); - if (Date.now() >= deadline) throw new Error('The native application fixture did not report readiness.'); - await delay(50); + deadline.throwIfAborted(); + await delay(50, undefined, { signal: deadline }); } } diff --git a/packages/agentic/desktop-test/src/wdio/nativeRunner.contract.test.ts b/packages/agentic/desktop-test/src/wdio/nativeRunner.contract.test.ts index 27e79820ce..556b990b49 100644 --- a/packages/agentic/desktop-test/src/wdio/nativeRunner.contract.test.ts +++ b/packages/agentic/desktop-test/src/wdio/nativeRunner.contract.test.ts @@ -1,8 +1,16 @@ -import { spawn } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; +import fs from 'node:fs'; import path from 'node:path'; const { nativeRunnerEnabled, nativeRunnerCaptureEnabled } = require('./nativeRunnerSupport.cjs'); let cleanupConfirmed = true; +type NativeRunnerResult = { + cleanupConfirmed?: boolean; + passed?: boolean; + capture?: string; + captureSkipped?: boolean; + captureSkipReason?: string; +}; for (const mode of ['handoff', 'operator-preempt', 'worker-crash', 'lost-response', 'capture']) { const nativeTest = @@ -14,28 +22,53 @@ for (const mode of ['handoff', 'operator-preempt', 'worker-crash', 'lost-respons async () => { if (!cleanupConfirmed) throw new Error('Not run: the preceding native scenario did not confirm cleanup.'); cleanupConfirmed = false; - const result = await new Promise<{ cleanupConfirmed?: boolean; passed?: boolean }>((resolve, reject) => { - const child = spawn(process.execPath, [path.join(__dirname, 'nativeRunner.contract.cjs'), mode], { - stdio: ['ignore', 'pipe', 'pipe'], - timeout: 180_000, + const contract = path.join(__dirname, 'nativeRunner.contract.cjs'); + const parent = path.resolve(__dirname, '..', '..', '..', '..', '..', 'artifacts', 'desktop', 'f11'); + fs.mkdirSync(parent, { recursive: true, mode: 0o700 }); + const root = fs.mkdtempSync(path.join(parent, `native-${mode}-`)); + let ownerPid: number | undefined; + let childClosed = false; + let result: NativeRunnerResult | undefined; + try { + result = await new Promise((resolve, reject) => { + const child = spawn(process.execPath, [contract, mode, root], { + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 180_000, + }); + ownerPid = child.pid; + let spawnError: Error | undefined; + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + child.stdout.on('data', (data: Buffer) => stdout.push(data)); + child.stderr.on('data', (data: Buffer) => stderr.push(data)); + child.once('error', (error) => (spawnError = error)); + child.once('close', (code) => { + childClosed = true; + try { + if (spawnError) throw spawnError; + const value = JSON.parse(Buffer.concat(stdout).toString('utf8')); + if (code !== 0 && value.passed) throw new Error('The native process failed after reporting a pass.'); + resolve(value); + } catch (error) { + reject(new Error(Buffer.concat(stderr).toString('utf8') || String(error))); + } + }); }); - const stdout: Buffer[] = []; - const stderr: Buffer[] = []; - child.stdout.on('data', (data: Buffer) => stdout.push(data)); - child.stderr.on('data', (data: Buffer) => stderr.push(data)); - child.once('error', reject); - child.once('close', (code) => { - try { - const value = JSON.parse(Buffer.concat(stdout).toString('utf8')); - if (code !== 0 && value.passed) throw new Error('The native process failed after reporting a pass.'); - resolve(value); - } catch (error) { - reject(new Error(Buffer.concat(stderr).toString('utf8') || String(error))); + cleanupConfirmed = result.cleanupConfirmed === true; + expect(result).toMatchObject({ mode, passed: true, cleanupConfirmed: true, evidence: path.join(root, 'evidence.json') }); + if (result.captureSkipped) console.info(`Native capture ${result.capture}: ${result.captureSkipReason}; ownership/cleanup only.`); + } finally { + if ((!result?.passed || !cleanupConfirmed) && childClosed && ownerPid !== undefined) { + const removed = spawnSync(process.execPath, [contract, 'cleanup', root, String(ownerPid)], { + encoding: 'utf8', + timeout: 15_000, + }); + if (removed.error || removed.status !== 0) { + cleanupConfirmed = false; + console.warn('Failed-case descriptor cleanup is unconfirmed; no further native case is allowed.'); } - }); - }); - cleanupConfirmed = result.cleanupConfirmed === true; - expect(result).toMatchObject({ mode, passed: true, cleanupConfirmed: true }); + } + } }, 210_000, ); diff --git a/packages/agentic/desktop-test/src/wdio/nativeRunnerSupport.cjs b/packages/agentic/desktop-test/src/wdio/nativeRunnerSupport.cjs index e7b1a416de..3519552805 100644 --- a/packages/agentic/desktop-test/src/wdio/nativeRunnerSupport.cjs +++ b/packages/agentic/desktop-test/src/wdio/nativeRunnerSupport.cjs @@ -2,6 +2,9 @@ const assert = require('node:assert/strict'); const path = require('node:path'); const { setTimeout: delay } = require('node:timers/promises'); +const caseTimeoutMs = 120_000; +const cleanupTimeoutMs = 30_000; + function nativeRunnerEnabled(platform, environment) { return ( (platform === 'darwin' || platform === 'win32') && @@ -11,13 +14,20 @@ function nativeRunnerEnabled(platform, environment) { } function nativeRunnerCaptureEnabled(platform, environment) { - return platform !== 'win32' || !(environment.FURN_DESKTOP_DRIVER_DISABLED_INPUT_FEATURES ?? '').trim(); + return (platform === 'darwin' || platform === 'win32') && !(environment.FURN_DESKTOP_DRIVER_DISABLED_INPUT_FEATURES ?? '').trim(); +} + +async function nativeRunnerCapture({ enabled, capture }) { + if (!enabled) return { status: 'skipped', skipped: true, reason: 'host input policy' }; + const reply = await capture(); + const artifact = reply.content.find(({ type }) => type === 'resource_link'); + return artifact ? { status: 'passed', artifact } : { status: 'unsupported', skipped: true, reason: 'endpoint did not provide capture' }; } /** * @returns {{ endpoint: 'macos' | 'win32', executable: string, args: string[], application: import('@fluentui-react-native/desktop-driver').NativeDesktopApplicationDescriptor, activateId: string, statusId: string }} */ -function nativeRunnerFixture(platform, applicationPath, title, readyFile, { capture = false } = {}) { +function nativeRunnerFixture(platform, applicationPath, title, readyFile, { capture = false, lifetimeMs = caseTimeoutMs + 15_000 } = {}) { if (platform === 'darwin') { return { endpoint: 'macos', @@ -34,15 +44,25 @@ function nativeRunnerFixture(platform, applicationPath, title, readyFile, { capt return { endpoint: 'win32', executable: applicationPath, - args: ['--title', title, '--offscreen', capture ? '0' : '1'], + args: ['--title', title, '--offscreen', capture ? '0' : '1', '--lifetime-ms', String(lifetimeMs)], application: { executablePath: applicationPath, windowTitle: title }, activateId: '1001', statusId: '1004', }; } -async function waitForNativeFixture({ host, target, fixture, statusId, onDisposed, timeoutMs = 60_000, intervalMs = 50 }) { - const signal = AbortSignal.timeout(timeoutMs); +async function waitForNativeFixture({ + host, + target, + fixture, + statusId, + onDisposed, + signal: caseSignal, + timeoutMs = 60_000, + intervalMs = 50, +}) { + const deadline = AbortSignal.timeout(timeoutMs); + const signal = caseSignal ? AbortSignal.any([caseSignal, deadline]) : deadline; let lease; try { for (;;) { @@ -71,7 +91,7 @@ async function waitForNativeFixture({ host, target, fixture, statusId, onDispose if (controls.some((control) => control.automationId === statusId && control.name === 'Ready')) return lease; } } - await delay(intervalMs); + await delay(intervalMs, undefined, { signal }); } } finally { try { @@ -83,4 +103,119 @@ async function waitForNativeFixture({ host, target, fixture, statusId, onDispose } } -module.exports = { nativeRunnerEnabled, nativeRunnerCaptureEnabled, nativeRunnerFixture, waitForNativeFixture }; +function nativeRunnerRecords(platform, root) { + return platform === 'win32' + ? { agentDescriptor: `${path.basename(root)}-agent.json`, runnerDescriptor: `${path.basename(root)}-runner.json` } + : { privateRoot: path.join(root, 'private'), agentDescriptor: 'agent-connection.json', runnerDescriptor: 'runner-connection.json' }; +} + +async function removeNativeRunnerDescriptors(storage, descriptors, ownerPid) { + const removed = []; + const retained = []; + for (const name of descriptors) { + const contents = await storage.read(name); + if (contents === undefined) continue; + let record; + try { + record = JSON.parse(contents); + } catch { + retained.push(name); + continue; + } + if ( + Number.isSafeInteger(ownerPid) && + ownerPid > 0 && + record?.version === 1 && + record.service?.pid === ownerPid && + (await storage.remove(name, contents)) + ) { + removed.push(name); + } else retained.push(name); + } + return { removed, retained }; +} + +function nativeRunnerExecution(counts, mode) { + if (mode === 'worker-crash') return { ...counts, accessibilityClick: 'not run (worker crash)', expectedFailure: true }; + assert.equal(counts.executed, 2, 'the native consumer must execute both ownership and accessibility input'); + assert.equal(counts.passed, 2); + assert.equal(counts.failed, 0); + assert.equal(counts.skipped, 0, 'required native accessibility input must not be silently skipped'); + return { ...counts, expectedExecuted: 2, accessibilityClick: 'passed' }; +} + +async function runNativeRunnerCase({ + work, + shutdown, + onError, + onAbort, + signal, + timeoutMs = caseTimeoutMs, + cleanupTimeoutMs: cleanupBudget = cleanupTimeoutMs, +}) { + const deadline = AbortSignal.timeout(timeoutMs); + const caseSignal = signal ? AbortSignal.any([signal, deadline]) : deadline; + const stopped = interrupted(caseSignal); + const abort = () => onAbort?.(caseSignal.reason); + if (caseSignal.aborted) abort(); + else caseSignal.addEventListener('abort', abort, { once: true }); + // AbortSignal.timeout is unref'd: pending work must stay alive long enough to reach its cleanup deadline. + const keepAlive = setInterval(() => undefined, timeoutMs + cleanupBudget); + let aborted = false; + let cleanupTimedOut = false; + try { + try { + await Promise.race([ + Promise.resolve().then(() => { + caseSignal.throwIfAborted(); + return work(caseSignal); + }), + stopped.promise, + ]); + } catch (error) { + aborted = caseSignal.aborted; + onError(error); + } finally { + stopped.dispose(); + caseSignal.removeEventListener('abort', abort); + } + } finally { + try { + const cleanupSignal = AbortSignal.timeout(cleanupBudget); + const cleanupStopped = interrupted(cleanupSignal); + try { + await Promise.race([Promise.resolve().then(shutdown), cleanupStopped.promise]); + } catch (error) { + cleanupTimedOut = cleanupSignal.aborted; + onError(error); + } finally { + cleanupStopped.dispose(); + } + } finally { + clearInterval(keepAlive); + } + } + return { aborted, cleanupTimedOut, timeoutMs, cleanupTimeoutMs: cleanupBudget }; +} + +function interrupted(signal) { + let onAbort; + const promise = new Promise((_, reject) => { + onAbort = () => reject(signal.reason); + if (signal.aborted) onAbort(); + else signal.addEventListener('abort', onAbort, { once: true }); + }); + return { promise, dispose: () => signal.removeEventListener('abort', onAbort) }; +} + +module.exports = { + nativeRunnerEnabled, + nativeRunnerCaptureEnabled, + nativeRunnerCapture, + nativeRunnerFixture, + waitForNativeFixture, + nativeRunnerRecords, + removeNativeRunnerDescriptors, + nativeRunnerExecution, + runNativeRunnerCase, +}; diff --git a/packages/agentic/desktop-test/src/wdio/nativeRunnerSupport.test.ts b/packages/agentic/desktop-test/src/wdio/nativeRunnerSupport.test.ts index 97583e67f5..18c618f068 100644 --- a/packages/agentic/desktop-test/src/wdio/nativeRunnerSupport.test.ts +++ b/packages/agentic/desktop-test/src/wdio/nativeRunnerSupport.test.ts @@ -1,4 +1,13 @@ -const { nativeRunnerEnabled, nativeRunnerCaptureEnabled, nativeRunnerFixture, waitForNativeFixture } = require('./nativeRunnerSupport.cjs'); +const { + nativeRunnerEnabled, + nativeRunnerCaptureEnabled, + nativeRunnerCapture, + nativeRunnerFixture, + waitForNativeFixture, + runNativeRunnerCase, + removeNativeRunnerDescriptors, + nativeRunnerExecution, +} = require('./nativeRunnerSupport.cjs'); describe('native runner selection', () => { const enabled = { FURN_NATIVE_DRIVER_TEST: '1', FURN_NATIVE_HANDOFF_APP_PATH: 'fixture' }; @@ -14,7 +23,7 @@ describe('native runner selection', () => { executable: application, endpoint: 'win32', application: { executablePath: application, windowTitle: 'exact-title' }, - args: ['--title', 'exact-title', '--offscreen', '1'], + args: ['--title', 'exact-title', '--offscreen', '1', '--lifetime-ms', '135000'], }); expect(() => nativeRunnerFixture('win32', 'D:\\Fixture.app', 'title', 'unused.ready')).toThrow(/fixture executable/); }); @@ -27,15 +36,111 @@ describe('native runner selection', () => { }); }); - test('hosted input policy skips capture without skipping transport and ownership', () => { + test.each(['darwin', 'win32'])('hosted %s input policy skips capture without skipping transport and ownership', (platform) => { const environment = { FURN_NATIVE_DRIVER_TEST: '1', FURN_NATIVE_HANDOFF_APP_PATH: 'fixture.exe', FURN_DESKTOP_DRIVER_DISABLED_INPUT_FEATURES: 'keyboard,physicalClick,wheel', }; - expect(nativeRunnerEnabled('win32', environment)).toBe(true); - expect(nativeRunnerCaptureEnabled('win32', environment)).toBe(false); - expect(nativeRunnerCaptureEnabled('win32', { ...environment, FURN_DESKTOP_DRIVER_DISABLED_INPUT_FEATURES: '' })).toBe(true); + expect(nativeRunnerEnabled(platform, environment)).toBe(true); + expect(nativeRunnerCaptureEnabled(platform, environment)).toBe(false); + expect(nativeRunnerCaptureEnabled(platform, { ...environment, FURN_DESKTOP_DRIVER_DISABLED_INPUT_FEATURES: '' })).toBe(true); + }); + + test('endpoint capture refusal is explicitly unsupported rather than a failed ownership contract', async () => { + await expect( + nativeRunnerCapture({ + enabled: true, + capture: async () => ({ isError: true, content: [{ type: 'text', text: 'Capture unsupported.' }] }), + }), + ).resolves.toMatchObject({ status: 'unsupported', skipped: true }); + const capture = jest.fn(); + await expect(nativeRunnerCapture({ enabled: false, capture })).resolves.toMatchObject({ status: 'skipped', skipped: true }); + expect(capture).not.toHaveBeenCalled(); + }); + + test('the internal deadline shuts down a case even when its pending work ignores cancellation', async () => { + const shutdown = jest.fn().mockResolvedValue(undefined); + const onError = jest.fn(); + const onAbort = jest.fn(); + let signal: AbortSignal | undefined; + await expect( + runNativeRunnerCase({ + work: (value: AbortSignal) => { + signal = value; + return new Promise(() => undefined); + }, + shutdown, + onError, + onAbort, + timeoutMs: 10, + cleanupTimeoutMs: 100, + }), + ).resolves.toMatchObject({ aborted: true, cleanupTimedOut: false }); + expect(signal?.aborted).toBe(true); + expect(shutdown).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledTimes(1); + expect(onAbort).toHaveBeenCalledTimes(1); + }); + + test('the internal deadline also bounds shutdown that never settles', async () => { + await expect( + runNativeRunnerCase({ + work: async () => undefined, + shutdown: () => new Promise(() => undefined), + onError: jest.fn(), + timeoutMs: 100, + cleanupTimeoutMs: 10, + }), + ).resolves.toMatchObject({ cleanupTimedOut: true }); + }); + + test('failed-case descriptor cleanup removes only records owned by the exited child', async () => { + const owned = JSON.stringify({ version: 1, service: { pid: 123 } }); + const foreign = JSON.stringify({ version: 1, service: { pid: 456 } }); + const records = new Map([ + ['case-agent.json', owned], + ['case-runner.json', foreign], + ]); + const storage = { + read: async (name: string) => records.get(name), + remove: jest.fn(async (name: string, contents: string) => records.get(name) === contents && records.delete(name)), + }; + await expect(removeNativeRunnerDescriptors(storage, ['case-agent.json', 'case-runner.json'], 123)).resolves.toEqual({ + removed: ['case-agent.json'], + retained: ['case-runner.json'], + }); + expect(records.get('case-runner.json')).toBe(foreign); + expect(storage.remove).toHaveBeenCalledTimes(1); + expect(storage.remove).toHaveBeenCalledWith('case-agent.json', owned); + }); + + test('failed-case cleanup never removes a descriptor replaced after its ownership check', async () => { + const original = JSON.stringify({ version: 1, service: { pid: 123 } }); + const replacement = JSON.stringify({ version: 1, service: { pid: 456 } }); + let contents = original; + const storage = { + read: async () => { + contents = replacement; + return original; + }, + remove: async (_name: string, expected: string) => expected === contents, + }; + await expect(removeNativeRunnerDescriptors(storage, ['case-agent.json'], 123)).resolves.toEqual({ + removed: [], + retained: ['case-agent.json'], + }); + expect(contents).toBe(replacement); + }); + + test('ownership alone cannot qualify a native consumer when accessibility input was not disabled', () => { + const ownershipOnly = { executed: 1, passed: 1, skipped: 1, failed: 0 }; + expect(() => nativeRunnerExecution(ownershipOnly, 'handoff')).toThrow(/both ownership and accessibility input/); + expect(nativeRunnerExecution({ executed: 2, passed: 2, skipped: 0, failed: 0 }, 'handoff')).toMatchObject({ + executed: 2, + expectedExecuted: 2, + accessibilityClick: 'passed', + }); }); test('native readiness waits for the exact window and ready control, then releases only its attached lease', async () => { From 71f2dcab20a4ac3943177eeedbbd330a05ea09ed Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 12 Nov 2026 09:34:48 -0800 Subject: [PATCH 12/23] docs: isolate project-local validation fixtures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3be5f341-831b-40d4-9582-337c8ea65709 --- references/agents/repository/development.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/references/agents/repository/development.md b/references/agents/repository/development.md index a68a2fd349..6803bcf037 100644 --- a/references/agents/repository/development.md +++ b/references/agents/repository/development.md @@ -129,6 +129,10 @@ settles (R-VALID-004): - Run working-directory-sensitive native tools from the owning app workspace. Flags such as CocoaPods' `--project-directory` select project files but do not change subprocess resolution roots. - Run `yarn` only after dependency manifests change or when a declared command fails because a dependency is missing. +- When temporary test data stays in an ignored project directory, set `TEMP`, `TMP`, `TMPDIR`, and + `GIT_CEILING_DIRECTORIES` to that directory's absolute path for the validation process. This prevents synthetic + Git-free repositories from discovering the enclosing worktree; fixtures initialized as their own Git repositories + still work normally. - Review small, localized diffs directly. Use an independent review agent only after focused validation passes and when the change still has an ambiguous or cross-cutting contract. - Keep sessions scoped to one reviewable outcome; start unrelated work in a fresh session. From 8fa899554e649f8b0fd1dd14171e69ceea6fc30d Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 12 Nov 2026 09:39:40 -0800 Subject: [PATCH 13/23] docs: bound parallel validation scheduling when needed Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3be5f341-831b-40d4-9582-337c8ea65709 --- references/agents/repository/development.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/references/agents/repository/development.md b/references/agents/repository/development.md index 6803bcf037..1f684e4134 100644 --- a/references/agents/repository/development.md +++ b/references/agents/repository/development.md @@ -133,6 +133,9 @@ settles (R-VALID-004): `GIT_CEILING_DIRECTORIES` to that directory's absolute path for the validation process. This prevents synthetic Git-free repositories from discovering the enclosing worktree; fixtures initialized as their own Git repositories still work normally. +- If concurrent package runs oversubscribe Jest worker pools, set `LAGE_POOL_CONCURRENCY=2` for the aggregate + (equivalently, `yarn lage buildci --concurrency 2`). This limits scheduling without changing test coverage, + assertions, or timeouts. - Review small, localized diffs directly. Use an independent review agent only after focused validation passes and when the change still has an ambiguous or cross-cutting contract. - Keep sessions scoped to one reviewable outcome; start unrelated work in a fresh session. From 17749fe1ecf91b8625c50dcb321dd809bba577fb Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 12 Nov 2026 09:50:00 -0800 Subject: [PATCH 14/23] docs(desktop-test): record portable Windows buildci qualification Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3be5f341-831b-40d4-9582-337c8ea65709 --- .../desktop-test/references/current-status.md | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/packages/agentic/desktop-test/references/current-status.md b/packages/agentic/desktop-test/references/current-status.md index 7c0c3102ef..d01f46d4d0 100644 --- a/packages/agentic/desktop-test/references/current-status.md +++ b/packages/agentic/desktop-test/references/current-status.md @@ -1414,3 +1414,57 @@ D:\dev\furn-w1\artifacts\desktop\f11\w1-fix\review-fixes\focused-green.json`: hosted CI and locked clean-cache `buildci`: **Not run**, reserved for the coordinator's post-review W1 grant and final validation. Post-commit changeset checking is recorded in the review handoff receipt. + +## Windows portable clean-cache/buildci — review fixes — 2026-11-12 UTC + +Tested source: `d94c8787dd76a1be08cbf2827cc57edb6a3a18f2`, including the +review fixes at `78236d3e8502b743055bf5cf3c48eed58a8926a3` and two +validation-guidance commits. Scope: portable repository validation only. +The coordinator authorized the W1 lock without `-RequirePhysical` while +the desktop was disconnected; no campaign, app, native fixture/helper +launch, native/GUI contract or physical console Ctrl+C was run. Endpoint, +session kind and native run IDs are not applicable. + +Receipts are under `artifacts\desktop\f11\w1-fix\portable-buildci`. +All aggregate commands ran through `with-native-lock.ps1 -Lane W1` +from the worktree root, with `FURN_NATIVE_DRIVER_TEST` unset: + +- `cmd.exe /d /c "yarn clean-lage-cache && yarn lage buildci"`: + cache clear **Passed** once; the initial aggregate **Failed**. With + project-local temporary files, Git discovery escaped synthetic test + repositories into the enclosing ignored worktree. The scripts suite + reported 58 passed / 18 failed (`buildci.log`). +- Setting `TEMP`, `TMP`, `TMPDIR` and `GIT_CEILING_DIRECTORIES` to the same + project-local scratch directory restores fixture isolation without a + product-code change. `yarn workspace @fluentui-react-native/scripts test`: + **Passed** 76 / failed zero (`scripts-git-isolation-green.log`). + The operating guidance is committed in `8cbead3f6`. +- The default-parallel `yarn lage buildci` rerun **Failed** the existing + 15-second cold-worker `StorybookUI` test (`buildci-rerun.log`). + `yarn workspace @fluentui-react-native/storybook-desktop-runtime test +StorybookUI --runInBand`: **Passed** both unchanged tests + (`storybook-ui-focused.log`); no assertions or deadlines were relaxed. +- `yarn lage buildci` with `LAGE_POOL_CONCURRENCY=2` and the isolated Git + boundary: **Passed**, exit zero, 5m 55.78s (`buildci-bounded.log`). + Lage reported 81 successful tasks, 97 reused fresh-cache tasks, and zero + failed/pending/aborted tasks. Cache reuse follows the one initial clear; + it is not native capability coverage. Scheduling guidance is committed + in `d94c8787d`. + +**Observed hygiene:** the pre-run audit was clean. After the first failed +aggregate, its portable Node test children drained naturally and a later +audit was clean; an aborted fake-host runner fixture was preserved outside +the formatted source tree. No process was terminated. The final aggregate +released the W1 lock by `2026-11-12T17:45:39.6650962Z`. The required +post-buildci audit returned exit 2, `FURN-AUDIT: 6 candidate process(es):`, +all foreign W3 Node test/build processes started after that release; no W1 +candidate survived (`post-buildci-audit.log`). Those processes were not +touched; that shared-host snapshot was not globally clean. The later +closeout audit **Passed**, exit zero: +`FURN-AUDIT: no FURN desktop processes found` (`closeout-audit.log`). + +Root `yarn format:check`, `yarn check-agent-instructions`, Markdown link +checks for the edited development guide, and committed `yarn change:check`: +**Passed**. Native consumer modes, the repeated native driver contracts, +optional native capture, physical console Ctrl+C, and hosted CI remain +**Not run**. No push was made. From 70740c749fde6c9cf61745e82a5922236a0af2a5 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 12 Nov 2026 10:06:30 -0800 Subject: [PATCH 15/23] Revert "docs: bound parallel validation scheduling when needed" This reverts commit d94c8787dd76a1be08cbf2827cc57edb6a3a18f2. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3be5f341-831b-40d4-9582-337c8ea65709 --- references/agents/repository/development.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/references/agents/repository/development.md b/references/agents/repository/development.md index 1f684e4134..6803bcf037 100644 --- a/references/agents/repository/development.md +++ b/references/agents/repository/development.md @@ -133,9 +133,6 @@ settles (R-VALID-004): `GIT_CEILING_DIRECTORIES` to that directory's absolute path for the validation process. This prevents synthetic Git-free repositories from discovering the enclosing worktree; fixtures initialized as their own Git repositories still work normally. -- If concurrent package runs oversubscribe Jest worker pools, set `LAGE_POOL_CONCURRENCY=2` for the aggregate - (equivalently, `yarn lage buildci --concurrency 2`). This limits scheduling without changing test coverage, - assertions, or timeouts. - Review small, localized diffs directly. Use an independent review agent only after focused validation passes and when the change still has an ambiguous or cross-cutting contract. - Keep sessions scoped to one reviewable outcome; start unrelated work in a fresh session. From c6a063e4033353996b356219dee0793bfadbda66 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 12 Nov 2026 10:06:31 -0800 Subject: [PATCH 16/23] Revert "docs: isolate project-local validation fixtures" This reverts commit 8cbead3f64ac82e66f1c4597b4620558a0ea2ec2. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3be5f341-831b-40d4-9582-337c8ea65709 --- references/agents/repository/development.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/references/agents/repository/development.md b/references/agents/repository/development.md index 6803bcf037..a68a2fd349 100644 --- a/references/agents/repository/development.md +++ b/references/agents/repository/development.md @@ -129,10 +129,6 @@ settles (R-VALID-004): - Run working-directory-sensitive native tools from the owning app workspace. Flags such as CocoaPods' `--project-directory` select project files but do not change subprocess resolution roots. - Run `yarn` only after dependency manifests change or when a declared command fails because a dependency is missing. -- When temporary test data stays in an ignored project directory, set `TEMP`, `TMP`, `TMPDIR`, and - `GIT_CEILING_DIRECTORIES` to that directory's absolute path for the validation process. This prevents synthetic - Git-free repositories from discovering the enclosing worktree; fixtures initialized as their own Git repositories - still work normally. - Review small, localized diffs directly. Use an independent review agent only after focused validation passes and when the change still has an ambiguous or cross-cutting contract. - Keep sessions scoped to one reviewable outcome; start unrelated work in a fresh session. From d56f09c305f2d4a963f95d03b8b2c8c2f6e067fc Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 12 Nov 2026 10:15:43 -0800 Subject: [PATCH 17/23] docs(desktop-test): record default-environment acceptance Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3be5f341-831b-40d4-9582-337c8ea65709 --- .../desktop-test/references/current-status.md | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/packages/agentic/desktop-test/references/current-status.md b/packages/agentic/desktop-test/references/current-status.md index d01f46d4d0..f81bf5c2f6 100644 --- a/packages/agentic/desktop-test/references/current-status.md +++ b/packages/agentic/desktop-test/references/current-status.md @@ -1468,3 +1468,57 @@ checks for the edited development guide, and committed `yarn change:check`: **Passed**. Native consumer modes, the repeated native driver contracts, optional native capture, physical console Ctrl+C, and hosted CI remain **Not run**. No push was made. + +## Windows default-environment acceptance and guidance reverts — 2026-11-12 UTC + +This supersedes the preceding controlled-environment acceptance receipt. +Tested source: `015998677c0912625e0f7e30b107c674a90fec4c`. Receipts: +`artifacts\desktop\f11\w1-fix\default-buildci`. + +**Observed original failures:** the first aggregate failed only +`@fluentui-react-native/scripts#test`: 18 negative assertions in +`scripts/src/tasks/agentInstructions.test.ts` expected diagnostics but received an +empty issues list. `exact-failures.json` records every exact test name, +location and `AssertionError [ERR_ASSERTION]` message. The earlier +project-local `TEMP` was an execution choice to keep transient files local, +not an F11 or nativeRunner requirement. It made the synthetic repositories +discover their ignored enclosing worktree. + +The checker/test blobs match branch base `04d8d8b13` and local `origin/main` +`505a2187b` exactly. An extracted, byte-identical base-source copy reproduced +all 18 failures with that override; its source files were then removed. +`baseline-red.json` and `source-comparison.json` retain the proof. The +separate later `StorybookUI` failure was `Exceeded timeout of 15000 ms for a +test` in `resets the whole Win32 preview for a new test generation without +resetting chrome preferences`. The entire runtime package is identical at +base/main/head; both unchanged focused tests pass with inherited defaults. +No full baseline aggregate is claimed, and no new F11 test required a fix. + +The first default-environment aggregate passed the instruction tests but +failed the existing driver test `abandons a walk when the caller +disconnects, journals it as cancelled, and keeps the session usable` at +`packages/agentic/desktop-driver/src/server/operations.test.ts:405`: expected `unique`, received +`inconclusive`. That test and find implementation match base/main; all +39 unchanged focused tests pass, and the unchanged default aggregate +retry passed. **Inferred:** these aggregate-only failures are timing-sensitive, +not evidence that the branch needs altered repository-wide test settings. + +Both out-of-scope guidance commits were reversed with new `git revert` +commits: `b5b82795a` reverts `d94c8787d`; `015998677` reverts `8cbead3f6`. +The development guide is restored exactly to its pre-guidance contents. +No assertions, test budgets, product code or machine-wide settings changed. + +**Final fresh acceptance:** from the worktree root, through +`with-native-lock.ps1 -Lane W1` without `-RequirePhysical`, +`cmd.exe /d /c "yarn clean-lage-cache && yarn lage buildci"` **Passed**, +exit zero, 2m 45.87s (`final-default-buildci.log`). `TEMP`/`TMP`/`TMPDIR` +were inherited unchanged; concurrency was default; Git-ceiling, Lage-pool +and native-driver opt-ins were absent. All 178 Lage tasks succeeded, with +zero cache hits, failures, pending or aborted tasks. Native capability +skips are not passes. + +The post-run process audit **Passed**, exit zero: +`FURN-AUDIT: no FURN desktop processes found` (`final-default-audit.log`). +No app, native fixture/helper, native/GUI contract or physical Ctrl+C was +launched; no campaign or push occurred. Native qualification remains +**Not run**, awaiting its separate grant. From 684d7cdb41ad5fe5e9c8bc0fa4dabd3a0286f8ea Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 12 Nov 2026 10:41:23 -0800 Subject: [PATCH 18/23] Fix built Windows fixture provisioning for hosted handoff checks Use Node ESM specifiers and the existing package-root anchor in the test-only Win32 fixture support module. Reproduce the hosted setup failure with a portable built-module import regression before repairing it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3be5f341-831b-40d4-9582-337c8ea65709 --- .../windows-fixture-esm-provisioning.md | 5 +++++ .../references/ci-integration.md | 7 +++++++ .../references/native-helpers.md | 8 ++++++++ .../src/native/nativeDriver.test.ts | 20 +++++++++++++++++++ .../src/native/windows/win32FixtureSupport.ts | 14 ++++++------- .../references/native-qualification.md | 6 ++++-- 6 files changed, 51 insertions(+), 9 deletions(-) create mode 100644 .changeset/windows-fixture-esm-provisioning.md diff --git a/.changeset/windows-fixture-esm-provisioning.md b/.changeset/windows-fixture-esm-provisioning.md new file mode 100644 index 0000000000..77dbed552f --- /dev/null +++ b/.changeset/windows-fixture-esm-provisioning.md @@ -0,0 +1,5 @@ +--- +'@fluentui-react-native/desktop-driver': patch +--- + +Make the test-only Win32 fixture provisioner loadable as built Node ESM so hosted Windows native MCP/WDIO consumer qualification can provision its fixture explicitly without a Jest loader. diff --git a/packages/agentic/desktop-driver/references/ci-integration.md b/packages/agentic/desktop-driver/references/ci-integration.md index 09bdbf2ef2..68e897d08c 100644 --- a/packages/agentic/desktop-driver/references/ci-integration.md +++ b/packages/agentic/desktop-driver/references/ci-integration.md @@ -121,6 +121,13 @@ This repository's current PR workflow provides examples in The Windows Storybook jobs upload their doctor report, successful native build log, and preserved failed-build diagnostics with the Storybook artifacts. +Both accessible Windows and Win32 jobs also explicitly provision the plain +Win32 fixture and select desktop-test's native MCP/WDIO consumer. Its four +ownership modes must pass, and each non-crash mode must execute both its +ownership and accessibility-input specs without skips. This fixture qualifies +generic native handoff, not React Native rendering or physical input. The +fixture provisioner loads as built Node ESM; the portable native-driver suite +checks that import without compiling or launching a native executable. Neither `prep` nor `smoke` may compile a helper after the explicit build step. Because GitHub-hosted Windows is not qualified to provide authoritative global input, the accessible jobs disable keyboard, physical-click, and wheel diff --git a/packages/agentic/desktop-driver/references/native-helpers.md b/packages/agentic/desktop-driver/references/native-helpers.md index 3ea2775f6e..8c71e54b02 100644 --- a/packages/agentic/desktop-driver/references/native-helpers.md +++ b/packages/agentic/desktop-driver/references/native-helpers.md @@ -161,6 +161,14 @@ not set this policy. Never edit an artifact or selection in place. Rebuild and allow the resolver to publish a new immutable selection. +Repository CI explicitly provisions the plain Win32 fixture through the built +test-only `lib/native/windows/win32FixtureSupport.js` module's +`fixtureExecutable()` function. That module supports direct Node ESM loading, +without Jest's source resolver, and anchors fixture sources in the driver +package rather than the caller's directory. Importing it is portable and does +not build or launch anything. Calling the builder requires the Windows +toolchain and publishes only to the fixture cache; it never launches the app. + ## Verification Every artifact is checked before use: diff --git a/packages/agentic/desktop-driver/src/native/nativeDriver.test.ts b/packages/agentic/desktop-driver/src/native/nativeDriver.test.ts index 6c6b19ed49..af23bf81ce 100644 --- a/packages/agentic/desktop-driver/src/native/nativeDriver.test.ts +++ b/packages/agentic/desktop-driver/src/native/nativeDriver.test.ts @@ -2,6 +2,7 @@ import { spawn, spawnSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { pathToFileURL } from 'node:url'; import { DesktopAuthority } from '../authority/grants'; import { createDesktopDriverClient } from '../client/DesktopDriverClient'; @@ -69,6 +70,25 @@ const signedMacosTest = process.platform === 'darwin' && macosSigningIdentity && process.env.FURN_NATIVE_DRIVER_TEST === '1' ? test : test.skip; describe('native driver build and resolution', () => { + test('loads Windows fixture provisioning from built ESM without a Jest loader', () => { + const module = pathToFileURL(path.resolve(__dirname, '../../lib/native/windows/win32FixtureSupport.js')).href; + const child = spawnSync( + process.execPath, + [ + '--input-type=module', + '--eval', + 'const fixture = await import(process.argv[1]); process.stdout.write(typeof fixture.fixtureExecutable);', + module, + ], + { encoding: 'utf8', timeout: 10_000 }, + ); + expect({ status: child.status, stderr: child.stderr, exportType: child.stdout }).toEqual({ + status: 0, + stderr: '', + exportType: 'function', + }); + }); + test('locates its published sources independently of the caller working directory', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'furn-native-caller-')); try { diff --git a/packages/agentic/desktop-driver/src/native/windows/win32FixtureSupport.ts b/packages/agentic/desktop-driver/src/native/windows/win32FixtureSupport.ts index 9cfa1bb6bd..69b516c675 100644 --- a/packages/agentic/desktop-driver/src/native/windows/win32FixtureSupport.ts +++ b/packages/agentic/desktop-driver/src/native/windows/win32FixtureSupport.ts @@ -5,16 +5,16 @@ import fs from 'node:fs'; import path from 'node:path'; import { promisify } from 'node:util'; -import { resolveDisabledInputFeatures } from '../../hosts/native/NativeDesktopHost'; -import type { NativeDisabledInputFeature } from '../../hosts/native/NativeDesktopHost'; -import { acquireDirectoryLock, delay, hashTree, resolveNativeDriverCacheRoot, sha256 } from '../filesystem'; -import { buildNativeDesktopDriver, FURN_DESKTOP_DRIVER_CACHE_ROOT } from '../nativeDriver'; +import { resolveDisabledInputFeatures } from '../../hosts/native/NativeDesktopHost.js'; +import type { NativeDisabledInputFeature } from '../../hosts/native/NativeDesktopHost.js'; +import { acquireDirectoryLock, delay, hashTree, resolveNativeDriverCacheRoot, sha256 } from '../filesystem.js'; +import { buildNativeDesktopDriver, FURN_DESKTOP_DRIVER_CACHE_ROOT } from '../nativeDriver.js'; import { nativeDriverPackageRoot } from '../packageRoot.cjs'; -import type { NativeDriverArtifact } from '../types'; -import { probeWindowsToolchain } from './buildWindowsDriver'; +import type { NativeDriverArtifact } from '../types.js'; +import { probeWindowsToolchain } from './buildWindowsDriver.js'; const execFileAsync = promisify(execFile); -const fixtureRoot = path.resolve(__dirname, '../../../fixtures/win32'); +const fixtureRoot = path.join(nativeDriverPackageRoot, 'fixtures', 'win32'); const megabyte = 1024 * 1024; let helperArtifactPromise: Promise | undefined; diff --git a/packages/agentic/desktop-test/references/native-qualification.md b/packages/agentic/desktop-test/references/native-qualification.md index d9b343866a..67c9473fb1 100644 --- a/packages/agentic/desktop-test/references/native-qualification.md +++ b/packages/agentic/desktop-test/references/native-qualification.md @@ -219,8 +219,10 @@ The same contract runs on Windows against the existing plain Win32 fixture, not an AppKit bundle or a React Native host. Provision `furn-win32-fixture.exe` explicitly with the driver package's test-only `lib/native/windows/win32FixtureSupport.js` `fixtureExecutable()` builder. -This builds into the native cache but does not launch anything. The consumer -test never builds or downloads its fixture. With the verified helper already +The built module loads directly with Node ESM, without a Jest loader; importing +it does not build or launch anything. Calling the builder uses the Windows +toolchain and native cache but does not launch anything. The consumer test +never builds or downloads its fixture. With the verified helper already provisioned, run from the repository root under the exclusive desktop lock: ```powershell From e7043b40949936a45abf17eb3f9d0f045940a121 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 12 Nov 2026 10:54:27 -0800 Subject: [PATCH 19/23] Record hosted fixture import failure and default-environment repair validation Keep nativeRunner and accessibility-input coverage explicitly not run for the original failed hosted jobs. Record the portable red/green repair and fresh 178-task default-environment acceptance separately from pending native and physical qualification. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3be5f341-831b-40d4-9582-337c8ea65709 --- .../references/current-status.md | 40 +++++++++++++++++++ .../desktop-test/references/current-status.md | 33 +++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/packages/agentic/desktop-driver/references/current-status.md b/packages/agentic/desktop-driver/references/current-status.md index 41fb171cf3..c3e7c59b57 100644 --- a/packages/agentic/desktop-driver/references/current-status.md +++ b/packages/agentic/desktop-driver/references/current-status.md @@ -623,3 +623,43 @@ packages\agentic\desktop-driver\references\current-status.md` passed (six links). Root `yarn oxfmt --check`, `yarn change:check` and `git diff --check` passed. No clean-cache `buildci` rerun was required for these evidence-only markdown edits. + +## Windows hosted native-runner fixture provisioning — 2026-11-12 UTC + +**Observed:** hosted PR run `43279226602` at branch source +`23f974a727ca2627690304cbecee0fa96cb6de83` failed before the selected native +MCP/WDIO consumer in Windows Storybook job `131395742955` and Win32 +accessible Storybook job `131395742289`. Both helpers passed `doctor`; the +Windows job additionally passed 1,254 driver tests with 45 capability/platform +skips. These are not nativeRunner passes: its ownership modes and accessibility +input were **Not run** in both jobs. + +The fixture provisioner's built ESM import failed with `ERR_MODULE_NOT_FOUND` +for its extensionless `NativeDesktopHost` dependency. The test-only source also +used CommonJS `__dirname`. The repair uses `.js` specifiers and the existing +package-root anchor, preserving the fixture source directory for both Jest and +built Node consumers. The C++ fixture, helper protocol, cache policy and native +entrypoints are unchanged. + +**Observed, portable:** tested repair source +`0e2dcb26ca156133cb577fc81869b23cebf1df3a`. From this package, +`yarn test nativeDriver -t 'loads Windows fixture provisioning from built ESM' +--runInBand` was **Failed as expected** before the repair with the exact hosted +import error, then **Passed** after building the correction. It imports the +actual built module in an ordinary Node subprocess, without Jest's resolver, +native compilation or application launch. + +`yarn format`, `yarn lint`, `yarn build` and `yarn test --runInBand` all +**Passed**; the complete portable driver suite passed 1,212 and skipped 88. +The root `yarn clean-lage-cache && yarn lage buildci`, serialized through +`with-native-lock.ps1 -Lane W1`, **Passed** with inherited temporary-directory +settings and default concurrency: 178 successful tasks, zero cache hits or +failures, 2m 52.86s. No native opt-in or physical-session requirement was used. +The first post-run audit reported another lane's portable Yarn cache-clean +process, not a process owned by this repair; it was not terminated. + +Receipts, exact commands, job logs and doctor artifacts are retained under +`artifacts\desktop\f11\w1-fix\push-ci`. Hosted requalification of the repaired +provisioner is **Not run** at this receipt. Local nativeRunner and physical +console Ctrl+C remain **Not run** because the desktop session is disconnected +and no desktop campaign is granted. Portable loading is not native evidence. diff --git a/packages/agentic/desktop-test/references/current-status.md b/packages/agentic/desktop-test/references/current-status.md index f81bf5c2f6..3eb02d521d 100644 --- a/packages/agentic/desktop-test/references/current-status.md +++ b/packages/agentic/desktop-test/references/current-status.md @@ -1522,3 +1522,36 @@ The post-run process audit **Passed**, exit zero: No app, native fixture/helper, native/GUI contract or physical Ctrl+C was launched; no campaign or push occurred. Native qualification remains **Not run**, awaiting its separate grant. + +## Windows hosted nativeRunner provisioning correction — 2026-11-12 UTC + +**Observed:** PR workflow `43279226602`, branch source +`23f974a727ca2627690304cbecee0fa96cb6de83`, selected the new Windows consumer +but did not reach it. Windows Storybook job `131395742955` and Win32 +accessible Storybook job `131395742289` both failed while importing the +driver's built fixture provisioner: `ERR_MODULE_NOT_FOUND` for an extensionless +dependency. All four nativeRunner ownership modes and their accessibility-input +specs are therefore **Not run**, not skipped or passed. No flake rerun was used. + +The owning driver repair is tested at +`0e2dcb26ca156133cb577fc81869b23cebf1df3a`: explicit Node ESM imports and the +existing package-root anchor replace Jest-only resolution and `__dirname`. +The portable regression reproduces the hosted failure before the correction +and passes afterward; it never compiles or starts the native fixture. The +fixture, native protocol, consumer opt-ins, cleanup rules and macOS path are +unchanged. The driver's provisioning receipt is in +`packages/agentic/desktop-driver/references/current-status.md`. + +**Observed, portable:** from the repository root, +`yarn workspace @fluentui-react-native/desktop-test test nativeRunnerSupport +--runInBand` **Passed**, all 18 cases. The driver suite passed 1,212 and skipped 88. The locked default-environment +`yarn clean-lage-cache && yarn lage buildci` **Passed**, all 178 tasks with +zero cache hits or failures, 2m 52.86s. Package format/lint/build, root format +and instruction checks, changed-document links and committed changesets passed. +Receipts are in `artifacts\desktop\f11\w1-fix\push-ci`. + +No local native/GUI workload or physical input was launched; no desktop +campaign was started. Hosted requalification is pending the correction's push. +Local nativeRunner and physical console Ctrl+C remain **Not run** while the +desktop session is disconnected. This receipt does not upgrade the earlier +connected endpoint evidence to a rerun of the new consumer. From 524b9a46a5077bd6180b3fdcec4e8561b43e2703 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 12 Nov 2026 11:18:07 -0800 Subject: [PATCH 20/23] Validate native runner response loss by its original transport cause Share the successful-response loss fault between portable and native consumers. Verify the typed transport error and exact injected cause while preserving busy fencing, cleanup and fresh-session assertions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3be5f341-831b-40d4-9582-337c8ea65709 --- .changeset/windows-runner-response-loss.md | 5 ++++ .../references/native-qualification.md | 5 ++++ .../src/wdio/nativeRunner.contract.cjs | 28 ++++-------------- .../src/wdio/nativeRunnerSupport.cjs | 29 +++++++++++++++++++ .../desktop-test/src/wdio/runner.contract.cjs | 19 ++++-------- 5 files changed, 50 insertions(+), 36 deletions(-) create mode 100644 .changeset/windows-runner-response-loss.md diff --git a/.changeset/windows-runner-response-loss.md b/.changeset/windows-runner-response-loss.md new file mode 100644 index 0000000000..2cb88bb040 --- /dev/null +++ b/.changeset/windows-runner-response-loss.md @@ -0,0 +1,5 @@ +--- +'@fluentui-react-native/desktop-test': patch +--- + +Validate native runner response loss through the client's typed transport failure and original cause. Share the real-successful-response fault with the portable runner contract while retaining reservation fencing, confirmed drain and fresh-session checks. diff --git a/packages/agentic/desktop-test/references/native-qualification.md b/packages/agentic/desktop-test/references/native-qualification.md index 67c9473fb1..00a68c56af 100644 --- a/packages/agentic/desktop-test/references/native-qualification.md +++ b/packages/agentic/desktop-test/references/native-qualification.md @@ -201,6 +201,11 @@ references/cursors, terminal reports, credential confinement, descriptor withdrawal, claim release, and preservation of the same external process incarnation. A failed mode does not suppress later modes when its cleanup is confirmed; unconfirmed cleanup blocks subsequent launches. +The response-loss fault discards an actual successful HTTP response and checks +the client's typed transport failure and exact injected cause, not an unwrapped +top-level message. The portable runner contract uses the same fault routine +against its real HTTP service and verifies reservation fencing and subsequent +worker drain without a native helper. Each subprocess has a 120-second internal `AbortSignal.timeout` case budget followed by at most 30 seconds of shutdown, below the parent's 180-second diff --git a/packages/agentic/desktop-test/src/wdio/nativeRunner.contract.cjs b/packages/agentic/desktop-test/src/wdio/nativeRunner.contract.cjs index 31d987d2ed..3a0c85c7ce 100644 --- a/packages/agentic/desktop-test/src/wdio/nativeRunner.contract.cjs +++ b/packages/agentic/desktop-test/src/wdio/nativeRunner.contract.cjs @@ -14,6 +14,7 @@ const { waitForNativeFixture, nativeRunnerRecords, removeNativeRunnerDescriptors, + expectRunnerResponseLoss, nativeRunnerExecution, runNativeRunnerCase, } = require('./nativeRunnerSupport.cjs'); @@ -292,31 +293,14 @@ async function main() { assert.throws(() => service.server.sessions.get(original.id), { code: 'invalid session id' }); } if (mode === 'lost-response') { - const fetcher = globalThis.fetch; - let dropped = false; - globalThis.fetch = async (input, init) => { - const response = await fetcher(input, init); - if (String(input).endsWith('/furn/grants/runner') && response.status === 200) { - dropped = true; - await response.body?.cancel(); + await expectRunnerResponseLoss({ + acquire: () => createDesktopRunnerBroker({ connection, targetId: target.id, addSecret: (secret) => run.addSecret(secret), signal }), + whileReserved: async () => { const busy = await call('desktop_session', { action: 'open', target: target.id }); assert(busy.isError); assert.equal(JSON.parse(busy.content[0].text).reason, 'target-busy'); - throw new Error('Injected runner acquisition response loss.'); - } - return response; - }; - try { - await assert.rejects( - createDesktopRunnerBroker({ connection, targetId: target.id, addSecret: (secret) => run.addSecret(secret), signal }), - { - message: 'Injected runner acquisition response loss.', - }, - ); - } finally { - globalThis.fetch = fetcher; - } - assert(dropped, 'the fault must discard a real successful runner-grant response'); + }, + }); const recovered = json(await call('desktop_session', { action: 'open', target: target.id })); assert.notEqual(recovered.id, original.id); json(await call('desktop_session', { action: 'close' })); diff --git a/packages/agentic/desktop-test/src/wdio/nativeRunnerSupport.cjs b/packages/agentic/desktop-test/src/wdio/nativeRunnerSupport.cjs index 3519552805..e7665a0a7e 100644 --- a/packages/agentic/desktop-test/src/wdio/nativeRunnerSupport.cjs +++ b/packages/agentic/desktop-test/src/wdio/nativeRunnerSupport.cjs @@ -135,6 +135,34 @@ async function removeNativeRunnerDescriptors(storage, descriptors, ownerPid) { return { removed, retained }; } +async function expectRunnerResponseLoss({ acquire, whileReserved = async () => undefined }) { + const fetcher = globalThis.fetch; + const injected = new Error('Injected runner acquisition response loss.'); + let dropped = false; + globalThis.fetch = async (input, init) => { + const response = await fetcher(input, init); + if (String(input).endsWith('/furn/grants/runner') && response.status === 200) { + dropped = true; + await response.body?.cancel(); + await whileReserved(); + throw injected; + } + return response; + }; + try { + await assert.rejects(acquire(), (error) => { + assert(error instanceof Error && 'reason' in error); + assert.equal(error.name, 'DesktopDriverClientError'); + assert.equal(error.reason, 'transport'); + assert.equal(error.cause, injected); + return true; + }); + } finally { + globalThis.fetch = fetcher; + } + assert(dropped, 'the fault must discard a real successful runner-grant response'); +} + function nativeRunnerExecution(counts, mode) { if (mode === 'worker-crash') return { ...counts, accessibilityClick: 'not run (worker crash)', expectedFailure: true }; assert.equal(counts.executed, 2, 'the native consumer must execute both ownership and accessibility input'); @@ -216,6 +244,7 @@ module.exports = { waitForNativeFixture, nativeRunnerRecords, removeNativeRunnerDescriptors, + expectRunnerResponseLoss, nativeRunnerExecution, runNativeRunnerCase, }; diff --git a/packages/agentic/desktop-test/src/wdio/runner.contract.cjs b/packages/agentic/desktop-test/src/wdio/runner.contract.cjs index b83b1c48f8..d732b35aac 100644 --- a/packages/agentic/desktop-test/src/wdio/runner.contract.cjs +++ b/packages/agentic/desktop-test/src/wdio/runner.contract.cjs @@ -2,6 +2,7 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const { pathToFileURL } = require('node:url'); +const { expectRunnerResponseLoss } = require('./nativeRunnerSupport.cjs'); async function main() { const mode = process.argv[2]; @@ -153,20 +154,10 @@ export default { onPrepare: probe, onWorkerStart: probe, onWorkerEnd: probe, bef }); assert.equal(attached, 1, 'a busy request never opens another native session'); await controller.revokeConnection(); - const fetcher = globalThis.fetch; - globalThis.fetch = async (input, init) => { - const response = await fetcher(input, init); - if (String(input).endsWith('/furn/grants/runner')) { - await response.body?.cancel(); - throw new Error('injected runner acquisition response loss'); - } - return response; - }; - try { - await assert.rejects(createDesktopRunnerBroker({ connection, targetId: target.id, addSecret: (secret) => run.addSecret(secret) })); - } finally { - globalThis.fetch = fetcher; - } + await expectRunnerResponseLoss({ + acquire: () => createDesktopRunnerBroker({ connection, targetId: target.id, addSecret: (secret) => run.addSecret(secret) }), + whileReserved: () => assert.rejects(client.newSession(capabilities), { code: 'session not created' }), + }); let failure; try { await runDesktopWdioSuite(runner, config, 'fixture', undefined, { connection }); From 632eb6fb5bd4b8f37f1b8894bedea4fdba9aceec Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 12 Nov 2026 12:07:21 -0800 Subject: [PATCH 21/23] Record Windows native runner and physical console qualification Record all native consumer modes, identity contracts, both physical Ctrl+C endpoints, and final process cleanup. Disclose the repeated default aggregate readiness failure separately from passing native and owning-package evidence. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3be5f341-831b-40d4-9582-337c8ea65709 --- .../references/current-status.md | 83 +++++++++++ .../references/current-status.md | 92 +++++++++++++ .../desktop-test/references/current-status.md | 130 ++++++++++++++++++ 3 files changed, 305 insertions(+) diff --git a/packages/agentic/desktop-driver-mcp/references/current-status.md b/packages/agentic/desktop-driver-mcp/references/current-status.md index ebfe2fc6bb..ec61f7ad25 100644 --- a/packages/agentic/desktop-driver-mcp/references/current-status.md +++ b/packages/agentic/desktop-driver-mcp/references/current-status.md @@ -471,3 +471,86 @@ packages\agentic\desktop-driver-mcp\references\current-status.md` passed (six links). Root `yarn oxfmt --check`, `yarn change:check` and `git diff --check` passed. No clean-cache `buildci` rerun was required for these evidence-only markdown edits. + +## Windows native consumer and physical serve cleanup — 2026-11-12 UTC + +**Observed, tested source `b1bbb26df880252a184ea5a3d2e1848bd811e83f`:** +the Windows generic private-descriptor MCP/WDIO consumer now executes against +an explicitly provisioned plain Win32 fixture. In the granted W1 campaign, +with an active unlocked RDP session (`sessionKind: remote`), native opt-in and +the exact fixture path, the locked command +`yarn workspace @fluentui-react-native/desktop-test test +nativeRunner.contract --runInBand --json --outputFile +\artifacts\desktop\f11\w1-native\native-runner-jest.json` **Passed** all +five outer cases, with zero skips. + +| MCP/runner mode | Result | Native WDIO run | +| ---------------------------------------------------- | --------------------------------------------- | -------------------------------------- | +| Explicit handoff and fresh MCP reopen | Passed, 2/2 inner specs | `a9849169-7f2b-46e6-87de-3e0b8b8e7a3f` | +| Operator preemption and exclusive runner reservation | Passed, 2/2 | `2a1f63bd-3ac0-4951-beb5-860a2a92b69c` | +| Worker crash and confirmed parent drain/reuse | Passed recovery; expected terminal failed run | `9c1a8447-2a6c-4ca6-b027-8bc302df3fc7` | +| Lost real HTTP-200 runner-grant response | Passed, 2/2 | `4e7d50fa-b055-407c-bfe2-443fe2bc504f` | +| Target capture and stale-resource invalidation | Passed, 2/2 and real capture | `047e8166-c3d2-4fb9-b785-288519b3342d` | + +Every non-crash mode executed the accessibility-input spec, rather than +silently skipping it. Each mode preserved the exact attached fixture +incarnation through service cleanup, removed both private descriptors and +the claim, then ended only its own fixture. Credential confinement and +fresh-session/stale-resource checks passed. This plain fixture qualifies +generic native ownership, not a React Native renderer or Storybook runner +handoff. The separate Windows driver identity/backend selection passed +24 and skipped 19 macOS cases. + +The previous hosted consumer in workflow `43284867711` ran on both Windows +job `131415841258` and Win32 accessible job `131415842551`: handoff, +preemption and crash recovery passed, but lost-response failed before WDIO +because it compared the injected error's message with the correctly wrapped +transport error. Capture was policy-skipped. The current harness correction +checks the typed failure's original cause and shares the actual successful +response-loss injection with the portable HTTP runner. Its native +lost-response pass above confirms that reservation/drain/reopen path; no +adapter runtime or error-redaction change was needed. + +**Observed, real Storybook consumers:** physical Ctrl+C also **Passed** +after authenticated `components-button--appearance` native-marker selection +and MCP observation on Windows Fabric and Win32 Paper. The MCP session and +adapter were closed before interruption. An isolated, visible, focused +console received four `SendInput` key events and recorded console `SIGINT`; +no `GenerateConsoleCtrlEvent` or synthetic Node signal was used. The exact +CLI, Yarn and observer each exited 0; both lifecycle reports are terminal +passed, descriptors were withdrawn, claims released and apps preserved until +their external launcher closed them. + +Fabric lifecycle: `75c40800-571c-4d8f-9fa1-debf876f0664`. +Paper lifecycle: `9a5c618f-d54d-4599-b36f-e3fd5b5436b9`. +Reports are under `apps\storybook\artifacts\desktop\`. +The unchanged registered Fabric native build was reused after identity/source +comparison. Paper used a serving-instance/driver-manifest-matched bundle +and the prebuilt REX host; its native build remains **Unsupported**. + +Every native/input command used +`with-native-lock.ps1 -Lane W1 -RequirePhysical`. Evidence and reproduction +are retained under `artifacts\desktop\f11\w1-native`, including +`contracts.json`, `physical-windows-UmfSj6`, `physical-win32-sssEyg` and +the session/lock/audit receipts. No private descriptor was copied into those +artifacts. The campaign is ended with zero W1 native/GUI survivors. The broad +audit's four remaining candidates are foreign portable W5 Jest/Yarn processes, +with no listeners; none was terminated. Corrected-head hosted confirmation is +pending publication at this receipt, not inferred from earlier green checks. + +**Portable closeout:** locked default-environment +`cmd.exe /d /c "yarn clean-lage-cache && yarn lage buildci"` **Failed** +three fresh attempts on the same Storybook integration readiness test, +`hosts the Storybook channel and Stage 1 smoke-test target in one process` +(`A stopping command cannot publish readiness.`). The test and lifecycle +source are unchanged from main/review base; the full owning package's +default-concurrency `yarn workspace +@fluentui-react-native/storybook-desktop test` **Passed** 340 with three +skips. No unrelated source/deadline or environment workaround was added, +and no passing aggregate is claimed for this head. Exact failures and +source comparison are retained under the same evidence root and in the +harness ledger. The last aggregate began with a clean audit, so the failure +is not attributed to the foreign portable chain. Owned portable workers +exited naturally; that foreign owner removed its abandoned processes. +Final audit **Passed**: `FURN-AUDIT: no FURN desktop processes found`. +Changed-ledger format, instruction and link checks also passed. diff --git a/packages/agentic/desktop-driver/references/current-status.md b/packages/agentic/desktop-driver/references/current-status.md index c3e7c59b57..cd778634b6 100644 --- a/packages/agentic/desktop-driver/references/current-status.md +++ b/packages/agentic/desktop-driver/references/current-status.md @@ -663,3 +663,95 @@ Receipts, exact commands, job logs and doctor artifacts are retained under provisioner is **Not run** at this receipt. Local nativeRunner and physical console Ctrl+C remain **Not run** because the desktop session is disconnected and no desktop campaign is granted. Portable loading is not native evidence. + +## Windows native fixture consumers and console cleanup — 2026-11-12 UTC + +**Observed at `b1bbb26df880252a184ea5a3d2e1848bd811e83f`:** explicit +`yarn desktop-driver build-driver --platform windows` and +`yarn desktop-driver doctor --platform windows` **Passed**, the latter +with `ready: true`. Built ESM fixture provisioning succeeded without Jest. +The C++ fixture and provider/protocol sources are unchanged; verified helper +artifact `d8ee05e4796fb3aa22f7b5dbf481bd8cdfb0c20b6d5d24676d689dc7900d5b3c` +was used. + +With `FURN_NATIVE_DRIVER_TEST=1`, the locked root command +`yarn workspace @fluentui-react-native/desktop-driver test +serviceIdentity.contract applicationIncarnation.contract nativeDriver +--runInBand --json --outputFile +\artifacts\desktop\f11\w1-native\native-driver-jest.json` **Passed**: +24 tests passed, zero failed, 19 macOS-only cases skipped. This mixed +selection includes the new ordinary-Node ESM import regression as well as +the Windows native identity/backend contracts. No skip is a native pass. + +The newly enabled Windows desktop-test consumer also **Passed** all five +outer modes against the plain Win32 fixture: handoff, operator preemption, +worker-crash recovery, lost successful runner-grant response and capture. +The four non-crash modes each executed two inner specs, including native +accessibility input, with zero skips. Crash recovery intentionally produced +a terminal failed WDIO run before reuse. Every mode confirmed native deletion, +descriptor withdrawal and workload-claim release while preserving the +external fixture; only the scenario's own launcher then ended it. +This qualifies generic native transport/ownership, not React Native rendering +or Storybook runner handoff. + +Separately, physical console Ctrl+C **Passed** for real Storybook +`desktop-test serve --agent` on both renderers in an active unlocked RDP +session (`sessionKind: remote`). Exact-console foreground/isolation checks +preceded four real `SendInput` keyboard events; the observer received console +`SIGINT`, and no semantic signal generation was used. Both apps had completed +authenticated Button story/native-marker and MCP observation checks; the +MCP session was closed before interrupting the long-lived service. + +| Endpoint | Terminal passed lifecycle | CLI / Yarn / observer exit | +| --------------------------- | -------------------------------------- | -------------------------- | +| Windows Fabric packaged app | `75c40800-571c-4d8f-9fa1-debf876f0664` | 0 / 0 / 0 | +| Win32 Paper REX | `9a5c618f-d54d-4599-b36f-e3fd5b5436b9` | 0 / 0 / 0 | + +The service withdrew its descriptor, released its claim and preserved the +same running app incarnation. Its recorded external launcher then closed +that app. Metro, channel and helper cleanup completed without survivors. +Fabric reused the verified registered `bdeb0b291` native build because its +Windows native sources are unchanged; the app config was inherited with an +explicit verified AUMID/title, not a title-only target. Paper used the prebuilt +REX host and a fresh bundle carrying the serving instance/manifest identity; +a Paper native build remains **Unsupported**. + +All live commands used the granted W1 campaign and +`with-native-lock.ps1 -Lane W1 -RequirePhysical`. Exact reproduction, +session/lock receipts, native identities, run IDs and per-mode results are in +`artifacts\desktop\f11\w1-native`; physical roots are +`physical-windows-UmfSj6` and `physical-win32-sssEyg`. +The owning harness reference +`packages/agentic/desktop-test/references/current-status.md` records its +commands and the historical Ctrl+C regression proof separately. + +Workflow `43284867711` reached the Windows native consumer in jobs +`131415841258` and `131415842551` after the built-import correction. +Both ran three successful ownership modes, including two accessibility specs, +but their lost-response case failed on an exact-message assertion before +WDIO. Source `b1bbb26d` repairs that test composition in desktop-test and +passes it natively; it does not change the client's legitimate transport-error +wrapping. Corrected-head hosted coverage is pending publication here. + +The campaign is ended. Broad audit exit 2 names only four foreign portable +W5 Jest/Yarn processes with no listeners; zero W1 native/GUI processes remain, +and no foreign process was stopped. Native helper builds, JavaScript-only +checks and capability skips are not substituted for the live results above. + +**Portable closeout:** three fresh locked default-environment +`cmd.exe /d /c "yarn clean-lage-cache && yarn lage buildci"` attempts +**Failed**, each only on the unchanged Storybook integration test +`hosts the Storybook channel and Stage 1 smoke-test target in one process` +with `A stopping command cannot publish readiness.`. The test and readiness +source match main and review base `04d8d8b13`; no unrelated source, +timeout, TEMP/Git-ceiling or concurrency workaround was added. The owning +package's full default-concurrency `yarn workspace +@fluentui-react-native/storybook-desktop test` **Passed** 340 with three +skips. This is not a passing aggregate receipt for the current source. +Details and the unchanged-source object IDs are in the harness ledger and +`artifacts\desktop\f11\w1-native\aggregate-readiness-source-comparison.json`. +The final attempt began with a clean audit; the failure is not attributed +to foreign work. Owned fail-fast portable workers exited naturally; the +foreign owner removed its abandoned processes. Final audit **Passed**: +`FURN-AUDIT: no FURN desktop processes found`. Ledger format, instruction +and link checks passed. diff --git a/packages/agentic/desktop-test/references/current-status.md b/packages/agentic/desktop-test/references/current-status.md index 3eb02d521d..41f3bb5f1d 100644 --- a/packages/agentic/desktop-test/references/current-status.md +++ b/packages/agentic/desktop-test/references/current-status.md @@ -1555,3 +1555,133 @@ campaign was started. Hosted requalification is pending the correction's push. Local nativeRunner and physical console Ctrl+C remain **Not run** while the desktop session is disconnected. This receipt does not upgrade the earlier connected endpoint evidence to a rerun of the new consumer. + +## Windows native runner and physical console interruption — 2026-11-12 UTC + +**Observed, source `b1bbb26df880252a184ea5a3d2e1848bd811e83f`:** the W1 +campaign ran with an active, unlocked RDP session (`sessionKind: remote`). +Every live command used the exclusive native lock and `-RequirePhysical`. +No input-feature mask, privacy change or implicit native download was used. + +The explicitly provisioned plain Win32 fixture now exercises the generic +MCP/WDIO consumer on Windows. From the repository root, with +`FURN_NATIVE_DRIVER_TEST=1` and `FURN_NATIVE_HANDOFF_APP_PATH` naming that +fixture, `yarn workspace @fluentui-react-native/desktop-test test +nativeRunner.contract --runInBand --json --outputFile +\artifacts\desktop\f11\w1-native\native-runner-jest.json` **Passed** all five +outer cases, with zero skips. This fixture is not a React Native renderer. + +| Mode | Result | Inner WDIO run ID | Executed / passed | +| -------------------------- | --------------------------------------------- | -------------------------------------- | ---------------------------------------- | +| Handoff | Passed | `a9849169-7f2b-46e6-87de-3e0b8b8e7a3f` | 2 / 2 | +| Operator preemption | Passed | `2a1f63bd-3ac0-4951-beb5-860a2a92b69c` | 2 / 2 | +| Worker crash | Passed recovery; expected terminal failed run | `9c1a8447-2a6c-4ca6-b027-8bc302df3fc7` | 0 / 0; interrupted by the injected crash | +| Lost runner-grant response | Passed | `4e7d50fa-b055-407c-bfe2-443fe2bc504f` | 2 / 2 | +| Capture | Passed, including a real target capture | `047e8166-c3d2-4fb9-b785-288519b3342d` | 2 / 2 | + +Every non-crash mode executed both ownership and accessibility-input specs, +with zero inner skips. Every mode preserved its exact external fixture +incarnation through service cleanup, withdrew both descriptors, released +its workload claim, wrote terminal reports, and then ended its own fixture. +No cleanup deadline fired. Full service run IDs and per-mode evidence are in +`artifacts\desktop\f11\w1-native\contracts.json`; the corresponding roots are +`native-handoff-5ZFlJt`, `native-operator-preempt-arz9P9`, +`native-worker-crash-FyizDx`, `native-lost-response-7745sB` and +`native-capture-6v7nle` under `artifacts\desktop\f11`. + +**Observed, physical console Ctrl+C:** the real app consumer ran +`yarn desktop-test --config --target + --reporter json serve --agent --descriptor `. +The ignored qualification config imports the app's actual config and adds +only the verified registered Fabric AUMID/title; it does not replace the +Storybook integration. A separate owned observer ran the repository's Yarn +entry in a new visible console. Console process inspection proved isolation +from the controller, and the emitter checked that exact window was foreground +immediately before inserting Ctrl-down, C-down, C-up and Ctrl-up with +`SendInput`. The observer recorded real console `SIGINT`; no +`GenerateConsoleCtrlEvent` or injected Node signal was used. + +| Endpoint | Result | Lifecycle run ID | Exact CLI / Yarn / observer exits | +| --------------------------- | ------ | -------------------------------------- | --------------------------------- | +| Windows Fabric packaged app | Passed | `75c40800-571c-4d8f-9fa1-debf876f0664` | 0 / 0 / 0 | +| Win32 Paper REX | Passed | `9a5c618f-d54d-4599-b36f-e3fd5b5436b9` | 0 / 0 / 0 | + +Both apps first completed authenticated `components-button--appearance` +selection with native marker verification and a native MCP observation. +The MCP session/adapter was closed before interrupting the still-running +service. Each interrupt inserted exactly four key events, wrote a terminal +`passed` lifecycle report, released the per-user claim and withdrew the +descriptor. Each borrowed app remained running through service shutdown, +then its exact recorded launcher closed it. No owned service/app survived. +This confirms the inherited #4478 repair on the physical Windows console +path; the historical borrowed-close fixture's red-before/green-after proof +remains separate from this physical evidence. + +Reproduction owners are retained as +`node artifacts\desktop\f11\w1-native\physical-case.cjs windows` and the +same command with `win32`, each inside the granted W1 campaign and +`with-native-lock.ps1 -Lane W1 -RequirePhysical`. Physical receipts are in +`physical-windows-UmfSj6` and `physical-win32-sssEyg` beneath that root. +The unchanged registered Fabric native build `bdeb0b291` was reused after +package identity and native-source comparison. Two earlier Fabric setup +attempts were **Not run** for Ctrl+C: missing generated renderer properties, +then no local built manifest. `yarn storybook prep --windows` and the +supported explicit registered identity resolved those prerequisites; both +failed startup attempts exited terminally with no retained claim. +Win32 native build is **Unsupported**; the prebuilt REX host used a fresh +`yarn storybook bundle --win32` with the serving instance's port, ID and +driver manifest, as the platform recipe requires. + +**Hosted correction:** workflow `43284867711` at prior branch head +`644388cc32e3b65854572ebe18abef6ecdce93b5` provisioned the fixture and +executed four ownership modes in Windows job `131415841258` and Win32 +accessible job `131415842551`. Each passed handoff, preemption and crash +recovery; handoff/preemption each executed both inner specs. Lost-response +failed before WDIO because its assertion expected an unwrapped error message; +capture was policy-skipped. This was not a flake. The current correction +shares that real HTTP-200 response-loss fault with the portable HTTP runner +and checks the typed transport error's exact original cause instead. It was +red with the hosted message mismatch, green after repair, and now passes the +native lost-response case above. The full portable harness suite **Passed** +396 with 12 native/platform skips; build and lint also passed. + +The campaign is ended. The post-campaign broad audit reported four +**foreign portable** W5 Jest/Yarn processes, no listeners and zero owned +native/GUI survivors; none was terminated. Exact audit, session and lock +receipts are under `artifacts\desktop\f11\w1-native`. Hosted confirmation of +the corrected head is pending publication at this receipt; those earlier +partial hosted counts are not a four-mode CI pass. + +**Portable closeout, not aggregate acceptance:** the locked default-environment +`cmd.exe /d /c "yarn clean-lage-cache && yarn lage buildci"` **Failed** in +three fresh attempts at this source. Each failed only +`@fluentui-react-native/storybook-desktop#test` → +`desktop Storybook server integration` → +`hosts the Storybook channel and Stage 1 smoke-test target in one process`, +with `A stopping command cannot publish readiness.` at +`packages/agentic/desktop-test/src/processControl.ts:112`. +Both that lifecycle source and +`packages/agentic/storybook-desktop/src/driver/serverIntegration.test.ts` +are byte-identical at main, review base `04d8d8b13` and the tested head. +Timing sensitivity is **Inferred**, not a reproduced base-runtime result: +`yarn workspace @fluentui-react-native/storybook-desktop test +serverIntegration --runInBand` **Passed** 9 with one platform skip, and +the whole unchanged package's default-concurrency +`yarn workspace @fluentui-react-native/storybook-desktop test` **Passed** +340 with three skips. No unrelated source or test deadline was changed. +TEMP/TMP/TMPDIR and concurrency stayed at inherited defaults; native opt-in, +Git-ceiling and Lage pool overrides were absent. This failed aggregate does +not replace the earlier source's passing receipt or qualify this head. + +The final attempt began with a clean audit; its same failure therefore is +not attributed to the foreign portable work. Its fail-fast return briefly +left owned portable Jest workers running; they subsequently exited +naturally, without a process kill. The foreign process owner separately +removed its abandoned W5 chain. The final audit **Passed**: +`FURN-AUDIT: no FURN desktop processes found`. Receipts +`default-buildci{,-rerun}.json`, `clean-default-buildci.json`, +`aggregate-readiness-source-comparison.json`, +`aggregate-readiness-{focused,package}.json` and +`aborted-aggregate-cleanup-audit.log` are under the same evidence root. +Formatting, instruction validation and all three changed-ledger link checks +also **Passed**. From 5dbadb53b10cd81ccf1e15739e213f0046d93323 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 12 Nov 2026 13:00:33 -0800 Subject: [PATCH 22/23] docs(desktop): record default Windows aggregate acceptance Record controlled main baseline, ownership-safe campaign-state audit, and the final unchanged-default 178-task head pass without overclaiming the readiness failure cause. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3be5f341-831b-40d4-9582-337c8ea65709 --- .../references/current-status.md | 26 ++++++ .../references/current-status.md | 30 +++++++ .../desktop-test/references/current-status.md | 82 +++++++++++++++++++ 3 files changed, 138 insertions(+) diff --git a/packages/agentic/desktop-driver-mcp/references/current-status.md b/packages/agentic/desktop-driver-mcp/references/current-status.md index ec61f7ad25..1e836bd2b3 100644 --- a/packages/agentic/desktop-driver-mcp/references/current-status.md +++ b/packages/agentic/desktop-driver-mcp/references/current-status.md @@ -554,3 +554,29 @@ is not attributed to the foreign portable chain. Owned portable workers exited naturally; that foreign owner removed its abandoned processes. Final audit **Passed**: `FURN-AUDIT: no FURN desktop processes found`. Changed-ledger format, instruction and link checks also passed. + +## Windows default-aggregate follow-up — 2026-11-12 UTC + +**Observed at `2bf750f9e`:** locked default +`cmd.exe /d /c "yarn clean-lage-cache && yarn lage buildci"` **Passed** +178 tasks, zero failed, at 20:52:21–20:55:29 UTC. No source, test deadline +or environment/concurrency workaround changed. Detached main first failed +a different driver cancellation/reuse assertion, then its unchanged default +repeat passed 178; the earlier Storybook readiness error's exact cause is +not proven pre-existing or attributed to campaign state. + +No agent/runner descriptor, active claim or generated lease survived. +Only two exact exited campaign application epochs were compare-and-removed +through verified native APIs; unrelated recovery records were retained. +The scratch worktree was removed, no app/OS setting changed, and final +process audit **Passed**, no FURN processes found. The owning harness +receipt in `packages/agentic/desktop-test/references/current-status.md` +records locations, timestamps and the causal-evidence limits. + +Hosted workflow `43297198219` finished **successfully**, zero failed jobs: +Windows `131458546719` and Win32 accessible `131458546805` each executed +four passing ownership modes with one capture-policy skip. Handoff, +preemption and lost-response each passed both inner specs, zero skips, +with `accessibilityClick: passed`. This confirms hosted generic native +consumer coverage; it does not broaden the earlier physical Storybook +or macOS evidence. diff --git a/packages/agentic/desktop-driver/references/current-status.md b/packages/agentic/desktop-driver/references/current-status.md index cd778634b6..0ac2a14626 100644 --- a/packages/agentic/desktop-driver/references/current-status.md +++ b/packages/agentic/desktop-driver/references/current-status.md @@ -755,3 +755,33 @@ to foreign work. Owned fail-fast portable workers exited naturally; the foreign owner removed its abandoned processes. Final audit **Passed**: `FURN-AUDIT: no FURN desktop processes found`. Ledger format, instruction and link checks passed. + +## Windows default-aggregate follow-up — 2026-11-12 UTC + +**Observed at `2bf750f9e`:** final locked default +`cmd.exe /d /c "yarn clean-lage-cache && yarn lage buildci"` **Passed** +178 tasks, zero failed, at 20:52:21–20:55:29 UTC. No source, deadline, +TEMP/Git-ceiling or concurrency change was made. A detached main baseline +first failed the driver cancellation/reuse test (`unique` expected, +`inconclusive` received); its unchanged default repeat passed all 178. +That different failure does not reproduce the earlier Storybook readiness +error. Exact causality of that error remains unproven. + +Native-store inspection found no live descriptors/claims. Only the two +campaign-owned bridge epochs were compare-and-removed at 20:46:53 UTC, +after exact recorded application/start-time matching and verified exit +proof; eleven unrelated recovery records were retained. No process, +registered app, firewall or loopback setting was changed. The scratch +worktree was removed, and final broad process audit **Passed**, no +FURN processes found. The owning harness receipt in +`packages/agentic/desktop-test/references/current-status.md` records state +locations/timestamps, both baseline results, the head receipt and the +inference boundary. + +Corrected-source hosted workflow `43297198219` also **Passed**, zero +failed jobs. Windows `131458546719` and Win32 accessible `131458546805` +each proved four ownership cases passed / one capture-policy skip / +zero failures; handoff, preemption and lost-response each executed both +inner specs with `accessibilityClick: passed`. Those hosted results and +the preceding physical campaign remain distinct from this portable +aggregate follow-up. diff --git a/packages/agentic/desktop-test/references/current-status.md b/packages/agentic/desktop-test/references/current-status.md index 41f3bb5f1d..d45a2bdfcc 100644 --- a/packages/agentic/desktop-test/references/current-status.md +++ b/packages/agentic/desktop-test/references/current-status.md @@ -1685,3 +1685,85 @@ removed its abandoned W5 chain. The final audit **Passed**: `aborted-aggregate-cleanup-audit.log` are under the same evidence root. Formatting, instruction validation and all three changed-ledger link checks also **Passed**. + +## Windows default-aggregate follow-up — 2026-11-12 UTC + +**Observed, source `2bf750f9e87919124eee9e0c567eb377603cc9cd`:** the final +locked default-environment +`cmd.exe /d /c "yarn clean-lage-cache && yarn lage buildci"` **Passed** +all 178 tasks, zero failed, from **20:52:21 to 20:55:29 UTC**. +TEMP/TMP/TMPDIR and concurrency remained inherited defaults; native opt-in, +Git-ceiling and Lage pool overrides were absent. No source, test deadline, +dependency manifest or environment guidance changed during this follow-up. +This supersedes the earlier lack of aggregate acceptance for this source, +without erasing the three failed attempts. + +The exact earlier failure was +`@fluentui-react-native/storybook-desktop#test`, +`packages/agentic/storybook-desktop/src/driver/serverIntegration.test.ts` → +`desktop Storybook server integration` → +`hosts the Storybook channel and Stage 1 smoke-test target in one process`: +`A stopping command cannot publish readiness.` at +`packages/agentic/desktop-test/src/processControl.ts:112`. + +**Controlled baseline:** a new detached worktree at +`origin/main` (`505a2187b0a2451aed7081c9260b343d55742f93`) used its own +immutable dependency restore, not linked branch build output. The same +locked default command **Failed** at 20:42:15–20:44:20 UTC, but differently: +`@fluentui-react-native/desktop-driver#test`, +`packages/agentic/desktop-driver/src/server/operations.test.ts` → +`Desktop Driver agent operations` → `the caller leaving` → +`abandons a walk when the caller disconnects, journals it as cancelled, and keeps the session usable`. +The post-cancellation `session.find()` expected `outcome: "unique"` and +received `"inconclusive"` at line 405. Storybook's task was incomplete, +so that run does **not** reproduce the readiness failure on main. +One unchanged default repeat at 20:47:34–20:50:39 UTC **Passed** all 178 +tasks, including Storybook. Both baseline audits were clean; the scratch +worktree and its remaining empty directories were removed before the head run. + +**Persistent-state audit, 20:41–20:46 UTC:** + +| Location / state | Observation and timestamp | +| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `%LOCALAPPDATA%\Microsoft\FluentUIReactNative\desktop-test\workload` | Empty: no interactive lock, claim, report or recovery file. Last modified at the initial inventory: 20:04:25 UTC. | +| `%LOCALAPPDATA%\com.microsoft.fluentui-react-native.desktop-driver` | No agent, runner or active service descriptor. Thirteen durable settlement records; directory last modified 19:40:25 UTC before cleanup. | +| `%LOCALAPPDATA%\Microsoft\FluentUIReactNative\desktop-driver\native` | Shared immutable native cache, not a descriptor/claim store; existing cache root dates from August 31. Left untouched. | +| `apps\storybook\storybook-desktop.generated` | No lease, claim, lock, ready or connection files. Windows/Win32 manifests modified 19:36:48 / 19:40:06 UTC; runtime instance 19:40:08 UTC; retained generated WDIO spec 18:00:37 UTC. | +| `apps\storybook\artifacts\desktop` | All eight existing `run.json` reports terminal. Physical Fabric/Paper reports last modified 19:37:28 / 19:40:25 UTC. No per-user `run.json` found. | +| Registered Fabric app | Existing verified `furn-81` build; package directory / manifest modified November 10 at 15:08:37 / 15:08:26 UTC. No registration change made by this campaign. | +| Firewall / loopback | Existing app rule and exemption only, queried read-only at 20:41:51 UTC. Those APIs expose no modification timestamps. Neither setting was changed. | + +Two bridge-epoch records belonged exactly to the physical campaign's Fabric +and Paper application incarnations, created at 19:37:24 and 19:40:22 UTC. +At **20:46:53 UTC**, the verified Windows storage/probe APIs confirmed +each recorded PID/start-time pair against its own qualification receipt +and proved that exact application had exited. Compare-and-remove deleted +only those two unchanged records. The other eleven records, including +unrelated quarantines, were preserved. These were settled recovery records, +not active claims or live descriptors. No process was killed. + +**Diagnosis boundary:** identical source was not runtime proof of +pre-existence. Main's first error differed; its repeat and the final head +both passed. The readiness test waits 20 seconds, and the reported guard +rejects a producer frame received after stop begins. Aggregate CPU load was +observed at 96% across 24 logical processors; load-sensitive startup/stop +timing remains **Inferred**, not an established root cause. The fake-host +server path does not open the native service/recovery store. Neither a +same-error main reproduction nor a causal repair by deleting those epoch +records is claimed. No unrelated runtime or timeout workaround was added. + +Final post-head process audit **Passed**: +`FURN-AUDIT: no FURN desktop processes found`. Baseline/head command +receipts, state inventories, exact-identity cleanup, scratch removal and +audits are retained in `artifacts\desktop\f11\w1-buildci-followup`. + +**Hosted confirmation:** workflow `43297198219` at branch source +`2bf750f9e` completed **successfully, zero failed jobs**. Windows Storybook +job `131458546719` and Win32 accessible job `131458546805` each recorded +four ownership cases passed, one capture-policy skip and zero failures. +Handoff, preemption and lost-response each executed/passed both inner +specs with zero skips and `accessibilityClick: passed`; worker-crash +recovery passed around its deliberately failed inner run. Downloaded +artifacts, not outer Jest totals alone, establish these counts. No flake +rerun was used. The existing physical/native qualification remains +separate; this follow-up launched no app and made no new native claim. From 7c95c6b6050e8a1cea1e2286b7582611735508f1 Mon Sep 17 00:00:00 2001 From: Jason Morse Date: Thu, 12 Nov 2026 22:42:13 +0000 Subject: [PATCH 23/23] docs(desktop): record rebased macOS handoff qualification Preserve Windows and F6 receipts, record the five positive native handoff modes and disclose default aggregate failures without unrelated test workarounds. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../references/current-status.md | 31 +++++ .../references/current-status.md | 35 +++++ .../desktop-test/references/current-status.md | 130 +++++++++++++++++- 3 files changed, 195 insertions(+), 1 deletion(-) diff --git a/packages/agentic/desktop-driver-mcp/references/current-status.md b/packages/agentic/desktop-driver-mcp/references/current-status.md index 1e836bd2b3..264893b692 100644 --- a/packages/agentic/desktop-driver-mcp/references/current-status.md +++ b/packages/agentic/desktop-driver-mcp/references/current-status.md @@ -580,3 +580,34 @@ preemption and lost-response each passed both inner specs, zero skips, with `accessibilityClick: passed`. This confirms hosted generic native consumer coverage; it does not broaden the earlier physical Storybook or macOS evidence. + +## F11 rebased macOS positive qualification — 2026-11-12 UTC + +**Observed at `5dbadb53b10cd81ccf1e15739e213f0046d93323`:** real native +descriptor verification, MCP SDK transport, and WDIO/Jasmine passed all five +generic AppKit modes: relinquish/handoff, operator preemption, worker crash, +lost successful runner-grant response, and target capture. Every non-crash +mode passed both inner ownership/accessibility-input specs with zero skips; +the intentional crash's terminal failed inner run was drain-confirmed before +a fresh MCP session. Stale resources and credential confinement passed. +All fixtures were preserved through service shutdown, descriptors removed, +claims released, and exact spawned fixtures subsequently ended. + +The stable-signed helper's noninteractive doctor reported Accessibility, +PostEvent and ScreenCapture **true**, with no prompt. This supersedes the +earlier macOS permission-blocked attempt, not the preserved Windows receipts. +The rebase retained all Windows additions from +`11820d6330f4ea591bed400193b13e793970b5bd` onto +`041ac8b9894aeaa7e1aa273baa2ece4e46298b94`; no runtime fix was needed. +MCP format, lint, build and **32 portable tests passed**; focused server tests +passed **31**. See `packages/agentic/desktop-test/references/current-status.md`, +under "F11 rebased macOS positive qualification — 2026-11-12 UTC", for exact +commands, native identities, run IDs and cleanup. +Storybook/F9, retained launches/F10, physical input, general MCP-client +qualification, cross-connection resume and replay remain separate. +Final portable aggregate acceptance remains **blocked** by the unchanged +driver bounded-command `SIGKILL`/`SIGTERM` assertion under plain default +parallel execution, despite passing owning/MCP suites and native qualification. +The owning harness receipt retains both failures, the additional first-run +private-storage timeout, and the passing focused diagnostic; no unrelated +test or environment workaround was applied. diff --git a/packages/agentic/desktop-driver/references/current-status.md b/packages/agentic/desktop-driver/references/current-status.md index 0ac2a14626..8fc366f290 100644 --- a/packages/agentic/desktop-driver/references/current-status.md +++ b/packages/agentic/desktop-driver/references/current-status.md @@ -785,3 +785,38 @@ zero failures; handoff, preemption and lost-response each executed both inner specs with `accessibilityClick: passed`. Those hosted results and the preceding physical campaign remain distinct from this portable aggregate follow-up. + +## F11 rebased macOS positive qualification — 2026-11-12 UTC + +**Observed at `5dbadb53b10cd81ccf1e15739e213f0046d93323`:** the original +`11820d6330f4ea591bed400193b13e793970b5bd` branch was rebased onto F6/main +`041ac8b9894aeaa7e1aa273baa2ece4e46298b94`, preserving every Windows addition +and both provenance/handoff ledgers. No runtime/native-provider fix was needed. + +Under the shared native lock, stable-signed noninteractive doctor reported +Accessibility, PostEvent and ScreenCapture **true**, no prompt, connected +unlocked console. The actual AppKit private-descriptor MCP/WDIO contract +**passed all five modes, zero skips**: handoff, operator preemption, worker +crash, lost successful grant response, and target capture. All four non-crash +modes executed/passed both inner ownership/accessibility-input specs without +skips. The deliberately failed crash worker was drain-confirmed before reuse. +Every mode preserved its exact external fixture through service shutdown, +withdrew both descriptors, released its claim, then ended only its own fixture. +The earlier macOS permission block is historical. + +The locked native `serviceIdentity.contract applicationIncarnation.contract +nativeDriver --runInBand` selection passed **28 / 15 Windows skips** (including +the portable built-ESM regression). Portable full driver tests passed +**1,235 / 66 skips**; format, lint and build passed. Exact commands, immutable +helper/fixture identities, per-mode run IDs, cleanup and evidence locators are +in `packages/agentic/desktop-test/references/current-status.md`, under +"F11 rebased macOS positive qualification — 2026-11-12 UTC". +This qualifies generic attach-only handoff, not RN rendering, Storybook/F9, +F10 retained launches, physical input or replay. Windows evidence above is +preserved historical evidence, not a rerun of the rewritten source. +Plain locked aggregate acceptance remains **failed**: the unchanged main +`boundedCommand.test.ts` expected `SIGKILL`, received `SIGTERM` on both +post-documentation-correction attempts. The first also timed out in private +storage; that passed on repeat. Focused diagnostics passed 73 / 10 skips, +but do not replace aggregate acceptance. No environment/concurrency or +unrelated source workaround was used; see the harness receipt for every result. diff --git a/packages/agentic/desktop-test/references/current-status.md b/packages/agentic/desktop-test/references/current-status.md index d45a2bdfcc..2ad5d2627d 100644 --- a/packages/agentic/desktop-test/references/current-status.md +++ b/packages/agentic/desktop-test/references/current-status.md @@ -30,7 +30,7 @@ and its sub-issues. | Durable quarantine wiring | Done | Both native service paths use owner-only storage and exact-incarnation probes; restart is not recovery proof. Live endpoint qualification remains separate. | | Supervisor crash containment | Work in progress | Cooperative Node service lifelines and correlated service readiness implemented; portable parent/wrapper crash, late-spawn, failed-withdrawal and stalled-child contracts. Live macOS survivors remain unqualified; no automatic reclaim. Windows: the helper's Job Object ends an unpackaged app it launched when the helper dies (qualified with REX); packaged launches are `uncontained` and survive in a fenced claim. Non-detached children are hard-terminated with a crashed owner (observed A/B), so the three crash-disposal contracts are skipped there. | | Storybook resource contributions | Work in progress | Current integration substitutes its CLI lifecycle; retain the channel/driver child until relocation is justified and qualified. | -| MCP/WDIO handoff | Work in progress | Generic attach-only phase implemented: bounded busy, explicit relinquish/operator-authorized preemption, settled deletion, target reservation, crash-drained worker grants, and fresh-session resources. Deterministic tests pass. Storybook phase waits for F9; retained launched apps and Windows/Win32 native qualification remain separate. | +| MCP/WDIO handoff | Work in progress | Generic attach-only phase implemented and qualified on real macOS AppKit and Windows Win32 fixtures: bounded busy, relinquish/operator preemption, settled deletion, exclusive reservation, worker-crash/lost-response drain, fresh resources and credential confinement. See the dated F11 receipts below. Storybook handoff still waits for F9; retained launches require F10 and explicit ownership. | | Supervisor-owned app lifetime | Work in progress | Current session-owned launch/externally owned attach semantics remain; never silently retain or terminate another owner's app. | | Cross-service/multi-target claims | Work in progress | Configured aliases now have distinct registered IDs while retaining physical identity; exact-application exclusion and input-mutex qualification remain open. | | Bounded persisted run/status/artifact queries | Done | Harness-local read service/client; atomic snapshots, terminal evidence, scoped expiring grants, bounded pages and allowlisted opaque artifact IDs. No management transport. | @@ -1767,3 +1767,131 @@ recovery passed around its deliberately failed inner run. Downloaded artifacts, not outer Jest totals alone, establish these counts. No flake rerun was used. The existing physical/native qualification remains separate; this follow-up launched no app and made no new native claim. + +## F11 rebased macOS positive qualification — 2026-11-12 UTC + +**Observed source:** `5dbadb53b10cd81ccf1e15739e213f0046d93323`, rebased onto +F6 squash/main `041ac8b9894aeaa7e1aa273baa2ece4e46298b94`. +The original branch/lease was `11820d6330f4ea591bed400193b13e793970b5bd`; +the historical base was `505a2187b0a2451aed7081c9260b343d55742f93`. +The owning worktree first fast-forwarded its older `c9753208d` tip, retaining +all fourteen Windows additions, then replayed all 22 branch commits. The +only conflict appended F6 and F11 evidence at the same location in this file: +both complete sections were retained. Range-diff reports 20 identical patches +and two documentation-context-only changes. Windows fixture/provisioning, +nativeRunner, input and response-loss code, hosted actions, and Windows ledgers +were preserved. No new runtime or native-provider behavior was changed. + +### Real native consumer + +macOS **27.2.1, arm64**, plain AppKit ownership fixture; no React Native +renderer. The explicitly provisioned, signature-verified +`FurnOwnershipFixture.app` uses the readiness-capable source +`27b109072002e5bd83a35c97ff9633311a74e5c7`, bundle identifier +`com.microsoft.furn.application-ownership-fixture`, unique per-mode titles, +and its exact ready-file/PID. It is not downloaded or compiled by the consumer. +The stable-signed helper artifact is +`e4a23c0ec3cc9ddcf07fde2153bdf260b7b85f65bd0a6851275dd55a6ccf0fd3`, +source digest +`b1d5233de326faf886c8d6310c2844c6857e4936686dfd493ff898f9e5ec6b9a`. +Noninteractive doctor reported **Accessibility, PostEvent and ScreenCapture +all true**, console connected/unlocked, and `promptRequested: false`. +The earlier permission-blocked macOS attempt remains historical, not the +current F11 qualification boundary. No permission, TCC or certificate change +was made. + +All commands below ran from the repository root. Native/helper commands, +portable suites and the final aggregate used the shared exclusive native lock. +`SIGNING` below denotes the existing `FURN Desktop Driver Development` +identity; the actual native commands set +`FURN_DESKTOP_DRIVER_MACOS_SIGNING_IDENTITY="FURN Desktop Driver Development"`. +`FIXTURE` denotes the absolute path to the owning worktree's +`artifacts/desktop/f11/application-fixture/FurnOwnershipFixture.app`. + +| Exact command (with the stated native environment) | Result | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| `yarn workspace @fluentui-react-native/desktop-driver exec desktop-driver build-driver --platform macos --json` | **Passed**, verified existing stable-signed artifact | +| `yarn workspace @fluentui-react-native/desktop-driver exec desktop-driver doctor --platform macos --permissions --json` | **Passed**, all three permission preflights true; no prompt | +| `codesign --verify --strict artifacts/desktop/f11/application-fixture/FurnOwnershipFixture.app` | **Passed** | +| `FURN_NATIVE_DRIVER_TEST=1 FURN_NATIVE_HANDOFF_APP_PATH="$FIXTURE" yarn workspace @fluentui-react-native/desktop-test test nativeRunner.contract --runInBand` | **Passed 5/5, zero skips**, 31.267 seconds | +| `FURN_NATIVE_DRIVER_TEST=1 yarn workspace @fluentui-react-native/desktop-driver test serviceIdentity.contract applicationIncarnation.contract nativeDriver --runInBand` | **Passed 28; skipped 15 Windows cases**. Selection includes portable built-ESM provisioning regression, not 28 purely native tests. | + +| Mode / receipt directory under `artifacts/desktop/f11/` | Inner WDIO run ID | Observed outcome | +| ------------------------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `native-handoff-cZN2uI` | `737589dd-cd90-493f-8bb1-251a4e057b72` | Explicit relinquish; 2 selected/executed/passed, zero skips | +| `native-operator-preempt-gaSxgd` | `76a5b401-0022-417c-a559-87b5d24c3753` | Operator-authorized preemption; 2 selected/executed/passed, zero skips | +| `native-worker-crash-D3uh4Z` | `861bc143-5388-4802-8194-e323ef317ee2` | Outer native drain/reuse contract passed; deliberate exit 17 leaves terminal **failed** inner run with 1 selected/incomplete, 0 completed; not an inner input pass | +| `native-lost-response-kklnxj` | `96b708e7-63a9-44c1-a0a3-eb3f4e87ff6b` | Real successful grant-response loss and confirmed drain; 2 selected/executed/passed, zero skips | +| `native-capture-xGUWSZ` | `791c6749-826e-4929-b35a-cf1cb8b9cadf` | Target-scoped capture; 2 selected/executed/passed, zero skips | + +Every non-crash mode passed the real accessibility-click spec, not merely the +outer Jest assertion. Every mode opened native sessions through verified +private discovery and the MCP SDK, exercised actual WDIO/Jasmine, rejected +stale resources, confirmed worker drain and credential confinement, withdrew +both descriptors, released its claim, and preserved the exact external +fixture incarnation through service shutdown. Only its owning test then +terminated that fixture and verified exit. All five receipts report +`cleanupConfirmed: true`; no case deadline or cleanup timeout fired. Captures +were confined to the registered fixture. No Metro or Storybook app was started. + +### Portable checks and scope + +`yarn install --immutable` **Passed** after the F6 manifest/lockfile update +(existing peer warnings). For each of `desktop-driver`, `desktop-test` and +`desktop-driver-mcp`, the exact commands +`yarn workspace @fluentui-react-native/ format`, +`yarn workspace @fluentui-react-native/ lint` and +`yarn workspace @fluentui-react-native/ build` each **Passed**. + +| Exact portable command | Result | +| ---------------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| `yarn workspace @fluentui-react-native/desktop-driver test 'authority/(grants | delegation) | server/(runnerHandoff | delegation)' --runInBand` | **Passed 51** | +| `yarn workspace @fluentui-react-native/desktop-test test runner --runInBand` | **Passed 20; skipped 5 native opt-ins**, separately executed above | +| `yarn workspace @fluentui-react-native/desktop-driver-mcp test server --runInBand` | **Passed 31** | +| `yarn workspace @fluentui-react-native/desktop-driver test --runInBand` | **Passed 1,235; skipped 66** | +| `yarn workspace @fluentui-react-native/desktop-test test --runInBand` | **Passed 402; skipped 6** | +| `yarn workspace @fluentui-react-native/desktop-driver-mcp test --runInBand` | **Passed 32** | + +`yarn lage test-links --verbose --grouped` **Passed all 90 tasks**. +The first locked plain `yarn clean-lage-cache && yarn lage buildci` +cleared the cache, then **failed** `check-agent-instructions`: two newly added +ledger links crossed package guidance boundaries (43 successful tasks, +85 pending). They were corrected to package-qualified text references; this +was a documentation error, not a native or runtime failure. + +The corrected `yarn check-agent-instructions` **Passed**. The next locked +plain `yarn lage buildci` **Failed** the driver task: `boundedCommand.test.ts` +expected `SIGKILL` but received `SIGTERM`, and the first macOS private-storage +publication test exceeded its 10-second budget (1,233 driver tests passed, +66 skipped, two failed). Both test files and their implementations are +byte-identical to pinned main. This establishes unchanged source, not a newly +executed baseline reproduction or the private-storage timeout's cause. +The locked diagnostic +`yarn workspace @fluentui-react-native/desktop-driver test boundedCommand privateStorage --runInBand` +**Passed 73, skipped 10** without source or deadline changes. + +One unchanged, locked plain `yarn lage buildci` repeat **Failed again** on +the same bounded-command `SIGKILL`/`SIGTERM` assertion: driver **1,234 passed, +66 skipped, one failed**; private storage passed. Lage reported **61 successful, +109 cached/skipped tasks, one failed**, with other tasks interrupted, so no +complete aggregate acceptance is claimed. The known parallel child-startup +race was not hidden, patched, or worked around; the diagnostic pass is not +aggregate acceptance. `TF_BUILD` and native opt-ins were unset, inherited +`TMPDIR` and default concurrency were unchanged, and the cache was cleared +once before the first aggregate. The exact failures remain in `buildci.log`, +`buildci-corrected.log`, and `buildci-default-repeat.log` beside the focused +diagnostic receipt. **The macOS positive native gate is closed; final portable +aggregate acceptance remains blocked.** + +Receipts and doctor output are under +`artifacts/desktop/f11/requalification-20261112/`; per-mode `evidence.json` +and terminal `run.json` files remain in the directories above. No private +descriptor or grant is copied into shared evidence. + +**Boundaries:** generic attach-only macOS handoff is positively qualified. +Existing Windows local/hosted evidence above is preserved, not re-executed on +this Mac or asserted for the rewritten SHA. Storybook/F9 handoff, F10 retained +launches, React Native renderer qualification, physical pointer/keyboard input, +cross-connection resume and automatic replay are **not qualified by this run** +and remain outside this phase. Publication, post-rebase hosted CI, and the PR +draft/readiness decision remain coordinator-owned.