diff --git a/apps/desktop/scripts/packaged-connect-evidence.mjs b/apps/desktop/scripts/packaged-connect-evidence.mjs index 9a30cc787..5d6ab13f5 100644 --- a/apps/desktop/scripts/packaged-connect-evidence.mjs +++ b/apps/desktop/scripts/packaged-connect-evidence.mjs @@ -4,10 +4,17 @@ export const PACKAGED_CONNECT_EXPECTED_DISCOVERY_COUNT = 10; export const PACKAGED_CONNECT_EVIDENCE_FAILURE_CODES = Object.freeze([ 'DISCOVERY_COUNT_MISMATCH', 'DISCOVERY_AUTHORIZATION_PRESENT', - 'PAIRING_START_COUNT_MISMATCH', + 'PAIRING_START_MISSING', + 'PAIRING_START_DUPLICATE', 'PAIRING_BROWSER_COUNT_MISMATCH', 'PAIRING_POLL_COUNT_MISMATCH', 'PAIRING_ACTIVATION_COUNT_MISMATCH', + 'PAIRING_METHOD_MISMATCH', + 'PAIRING_BROWSER_CREDENTIAL_PRESENT', + 'PAIRING_INTENT_SEQUENCE_MISMATCH', + 'PAIRING_LIFECYCLE_ISOLATION_FAILED', + 'PAIRING_REQUEST_AFTER_TERMINAL', + 'DELAYED_APPROVAL_READINESS_MISSING', 'BOOTSTRAP_AUTHORIZATION_PRESENT', 'AUTHENTICATED_REST_COUNT_MISMATCH', 'AUTHENTICATED_SOCKET_COUNT_MISMATCH', @@ -41,10 +48,17 @@ const failureChecks = Object.freeze([ ['DISCOVERY_COUNT_MISMATCH', evidence => evidence.discoveryCount !== PACKAGED_CONNECT_EXPECTED_DISCOVERY_COUNT], ['DISCOVERY_AUTHORIZATION_PRESENT', evidence => evidence.discoveryAuthorizationPresent], - ['PAIRING_START_COUNT_MISMATCH', evidence => evidence.pairingStartCount !== 3], + ['PAIRING_START_MISSING', evidence => evidence.pairingStartCount < 3], + ['PAIRING_START_DUPLICATE', evidence => evidence.pairingStartCount > 3], ['PAIRING_BROWSER_COUNT_MISMATCH', evidence => evidence.pairingBrowserCount !== 3], ['PAIRING_POLL_COUNT_MISMATCH', evidence => evidence.pairingPollCount !== 1], ['PAIRING_ACTIVATION_COUNT_MISMATCH', evidence => evidence.pairingActivationCount !== 1], + ['PAIRING_METHOD_MISMATCH', evidence => !evidence.pairingMethodBoundaryValid], + ['PAIRING_BROWSER_CREDENTIAL_PRESENT', evidence => evidence.pairingBrowserCredentialPresent], + ['PAIRING_INTENT_SEQUENCE_MISMATCH', evidence => !evidence.pairingIntentSequenceValid], + ['PAIRING_LIFECYCLE_ISOLATION_FAILED', evidence => !evidence.pairingLifecycleIsolated], + ['PAIRING_REQUEST_AFTER_TERMINAL', evidence => evidence.pairingRequestAfterTerminal], + ['DELAYED_APPROVAL_READINESS_MISSING', evidence => !evidence.delayedApprovalReadinessProven], ['BOOTSTRAP_AUTHORIZATION_PRESENT', evidence => evidence.bootstrapAuthorizationPresent], ['AUTHENTICATED_REST_COUNT_MISMATCH', evidence => evidence.authenticatedRestCount < 2], ['AUTHENTICATED_SOCKET_COUNT_MISMATCH', evidence => evidence.authenticatedSocketCount < 2], diff --git a/apps/desktop/scripts/packaged-connect-evidence.test.mjs b/apps/desktop/scripts/packaged-connect-evidence.test.mjs index 6afa7aeb7..3883cf3a7 100644 --- a/apps/desktop/scripts/packaged-connect-evidence.test.mjs +++ b/apps/desktop/scripts/packaged-connect-evidence.test.mjs @@ -15,6 +15,12 @@ const passingEvidence = () => ({ pairingBrowserCount: 3, pairingPollCount: 1, pairingActivationCount: 1, + pairingMethodBoundaryValid: true, + pairingBrowserCredentialPresent: false, + pairingIntentSequenceValid: true, + pairingLifecycleIsolated: true, + pairingRequestAfterTerminal: false, + delayedApprovalReadinessProven: true, bootstrapAuthorizationPresent: false, authenticatedRestCount: 2, authenticatedSocketCount: 2, @@ -31,10 +37,17 @@ const passingEvidence = () => ({ const failingEvidence = Object.freeze({ DISCOVERY_COUNT_MISMATCH: { discoveryCount: 8 }, DISCOVERY_AUTHORIZATION_PRESENT: { discoveryAuthorizationPresent: true }, - PAIRING_START_COUNT_MISMATCH: { pairingStartCount: 2 }, + PAIRING_START_MISSING: { pairingStartCount: 2 }, + PAIRING_START_DUPLICATE: { pairingStartCount: 4 }, PAIRING_BROWSER_COUNT_MISMATCH: { pairingBrowserCount: 2 }, PAIRING_POLL_COUNT_MISMATCH: { pairingPollCount: 2 }, PAIRING_ACTIVATION_COUNT_MISMATCH: { pairingActivationCount: 2 }, + PAIRING_METHOD_MISMATCH: { pairingMethodBoundaryValid: false }, + PAIRING_BROWSER_CREDENTIAL_PRESENT: { pairingBrowserCredentialPresent: true }, + PAIRING_INTENT_SEQUENCE_MISMATCH: { pairingIntentSequenceValid: false }, + PAIRING_LIFECYCLE_ISOLATION_FAILED: { pairingLifecycleIsolated: false }, + PAIRING_REQUEST_AFTER_TERMINAL: { pairingRequestAfterTerminal: true }, + DELAYED_APPROVAL_READINESS_MISSING: { delayedApprovalReadinessProven: false }, BOOTSTRAP_AUTHORIZATION_PRESENT: { bootstrapAuthorizationPresent: true }, AUTHENTICATED_REST_COUNT_MISMATCH: { authenticatedRestCount: 1 }, AUTHENTICATED_SOCKET_COUNT_MISMATCH: { authenticatedSocketCount: 1 }, diff --git a/apps/desktop/scripts/packaged-connect-lifecycle.test.mjs b/apps/desktop/scripts/packaged-connect-lifecycle.test.mjs index eb2e5b830..8754d7261 100644 --- a/apps/desktop/scripts/packaged-connect-lifecycle.test.mjs +++ b/apps/desktop/scripts/packaged-connect-lifecycle.test.mjs @@ -109,11 +109,19 @@ describe('packaged Connect bounded child lifecycle', () => { assert.match(accounting, /pairingActivationCount: pairingActivations\.length/u); const evaluator = await readFile(new URL('./packaged-connect-evidence.mjs', import.meta.url), 'utf8'); - assert.match(evaluator, /evidence\.pairingStartCount !== 3/u); + assert.match(evaluator, /evidence\.pairingStartCount < 3/u); + assert.match(evaluator, /evidence\.pairingStartCount > 3/u); assert.match(evaluator, /evidence\.pairingBrowserCount !== 3/u); assert.match(evaluator, /evidence\.pairingPollCount !== 1/u); assert.match(evaluator, /evidence\.pairingActivationCount !== 1/u); + assert.match(harness, /request\.method === 'POST'[\s\S]*?request\.url === '\/api\/desktop\/pairings'/u); + assert.match(harness, /request\.method === 'GET'[\s\S]*?\/\\\/browser\$\//u); + assert.match(harness, /pairingBrowserCredentialPresent: pairingBrowsers\.some/u); assert.doesNotMatch(evaluator, /pairingPollCount < 3/u); + assert.match(harness, /const approvalReadinessDelayMs = process\.platform === 'darwin' \? 300 : 0/u); + assert.match(harness, /pairingIntentSequenceValid: hasExactModes\(pairingStarts\) && hasExactModes\(pairingBrowsers\)/u); + assert.match(harness, /pairingRequestAfterTerminal: bootstrap\.length !== pairingRequestCountAtPairTerminal/u); + assert.match(harness, /delayedApprovalReadinessProven: process\.platform !== 'darwin'/u); }); test('accepts an exact ready proof followed by a clean exit', async () => { @@ -423,6 +431,8 @@ describe('packaged Connect bounded child lifecycle', () => { assert.ok(manual >= 0 && browser >= 0 && credential >= 0 && reprobeReady >= 0 && activation >= 0 && publication >= 0 && react >= 0); assert.match(main, /await stages\.waitFor\('CREDENTIAL_COMMITTED'\)[\s\S]*?await stages\.waitFor\('AUTHENTICATED_REPROBE_READY'\)[\s\S]*?await stages\.waitFor\('ACTIVATION_COMMITTED'\)[\s\S]*?await stages\.waitFor\('ACTIVATION_PUBLISHED'\)[\s\S]*?await stages\.waitFor\('REACT_CONNECTED'\)/u); + assert.match(main, /Packaged pairing expiry classification failed[\s\S]*?await waitForApprovalIdle\(\)[\s\S]*?JOURNEY_NEGATIVE_CANCEL[\s\S]*?const approvalReady = waitForNextApproval\(\)[\s\S]*?await approvalReady[\s\S]*?Packaged pairing cancellation classification failed[\s\S]*?await waitForApprovalIdle\(\)/u); + assert.match(main, /await waitForApprovalIdle\(\);\s+reportPackagedConnectJourneyStage\(phase === 'pair'\s+\? 'JOURNEY_PAIR_COMPLETE'/u); assert.match(main, /if \(packagedSmokeTest && !transportSmoke && !connectJourney\)/u); assert.match(main, /if \(packagedSmokeTest && !connectJourney\) \{/u); assert.doesNotMatch(main, /JOURNEY_PAIR_RENDERER|JOURNEY_REPROBE_RENDERER/u); diff --git a/apps/desktop/scripts/packaged-connect-platform.test.mjs b/apps/desktop/scripts/packaged-connect-platform.test.mjs index a37981688..d97b4d454 100644 --- a/apps/desktop/scripts/packaged-connect-platform.test.mjs +++ b/apps/desktop/scripts/packaged-connect-platform.test.mjs @@ -114,7 +114,7 @@ describe('packaged Connect target-native credential setup', () => { const stable = darwinRunner.indexOf('node "$signature_verifier" stable'); assert.ok(establish >= 0 && establish < smoke && smoke < stable); assert.match(packagedConnectSmoke, /const runPhase = async phase => await runPackagedConnectLifecycle\([\s\S]*?spawn: spawnLifecycleProcess/u); - assert.match(packagedConnectSmoke, /outcome = await runPhase\('pair'\);[\s\S]*?if \(outcome\.ok && journeyFixture\) \{\s*outcome = await runPhase\('reprobe'\);/u); + assert.match(packagedConnectSmoke, /outcome = await runPhase\('pair'\);\s*if \(outcome\.ok && journeyFixture\) \{\s*const pairingRequestCountAtPairTerminal = journeyFixture\.requests\.filter\(request =>[^{};]+\)\.length;\s*outcome = await runPhase\('reprobe'\);/u); assert.match(workflow, /target: darwin-x64\s+runner: macos-15-intel\s+platform: darwin\s+arch: x64/u); assert.match(workflow, /target: darwin-arm64\s+runner: macos-15\s+platform: darwin\s+arch: arm64/u); }); diff --git a/apps/desktop/scripts/smoke-packaged-connect.mjs b/apps/desktop/scripts/smoke-packaged-connect.mjs index d1bc37125..b6cebc1bf 100644 --- a/apps/desktop/scripts/smoke-packaged-connect.mjs +++ b/apps/desktop/scripts/smoke-packaged-connect.mjs @@ -98,6 +98,8 @@ const createPackagedJourneyFixture = async () => { let active = false; let binding; let mode = 'success'; + let modeGeneration = 0; + const approvalReadinessDelayMs = process.platform === 'darwin' ? 300 : 0; const cors = { 'Access-Control-Allow-Credentials': 'true', 'Access-Control-Allow-Headers': 'Authorization, Content-Type, X-ProPR-Desktop-Transport-Scope', @@ -130,7 +132,11 @@ const createPackagedJourneyFixture = async () => { authorization: request.headers.authorization ?? null, origin: request.headers.origin ?? null, transportScope: request.headers[DESKTOP_TRANSPORT_SCOPE_HEADER.toLowerCase()] ?? null, + credentialHeadersPresent: Object.keys(request.headers).some(name => + ['authorization', 'cookie', 'proxy-authorization'].includes(name.toLowerCase())), socketIo: false, + fixtureMode: mode, + fixtureModeGeneration: modeGeneration, }; requests.push(record); if (request.method === 'OPTIONS') { @@ -145,6 +151,7 @@ const createPackagedJourneyFixture = async () => { throw new Error('invalid fixture mode'); } mode = requestedMode; + modeGeneration += 1; approved = false; binding = undefined; response.writeHead(204, cors); @@ -208,6 +215,12 @@ const createPackagedJourneyFixture = async () => { return; } if (request.method === 'GET' && request.url === `/api/desktop/pairings/${pairingId}/browser`) { + if (approvalReadinessDelayMs > 0) { + await new Promise(resolve => setTimeout(resolve, approvalReadinessDelayMs)); + record.approvalReadinessDelayed = true; + } + record.fixtureModeStable = record.fixtureMode === mode + && record.fixtureModeGeneration === modeGeneration; approved = true; response.writeHead(200, { 'Cache-Control': 'no-store', 'Content-Type': 'text/html' }); response.end('Desktop approved

Approved

'); @@ -601,6 +614,10 @@ try { }); outcome = await runPhase('pair'); if (outcome.ok && journeyFixture) { + const pairingRequestCountAtPairTerminal = journeyFixture.requests.filter(request => + request.method !== 'OPTIONS' + && (request.url === '/api/desktop/pairings' + || /^\/api\/desktop\/pairings\/[^/]+\/(?:browser|poll|activate)$/u.test(request.url ?? ''))).length; outcome = await runPhase('reprobe'); if (outcome.ok) { const applicationRequests = journeyFixture.requests.filter(request => request.method !== 'OPTIONS'); @@ -609,10 +626,17 @@ try { request.url === '/api/desktop/pairings' || /^\/api\/desktop\/pairings\/[^/]+\/(?:poll|activate)$/u.test(request.url ?? '') || /\/browser$/u.test(request.url ?? '')); - const pairingStarts = bootstrap.filter(request => request.url === '/api/desktop/pairings'); - const pairingBrowsers = bootstrap.filter(request => /\/browser$/u.test(request.url ?? '')); - const pairingPolls = bootstrap.filter(request => /\/poll$/u.test(request.url ?? '')); - const pairingActivations = bootstrap.filter(request => /\/activate$/u.test(request.url ?? '')); + const pairingStarts = bootstrap.filter(request => request.method === 'POST' + && request.url === '/api/desktop/pairings'); + const pairingBrowsers = bootstrap.filter(request => request.method === 'GET' + && /\/browser$/u.test(request.url ?? '')); + const pairingPolls = bootstrap.filter(request => request.method === 'POST' + && /\/poll$/u.test(request.url ?? '')); + const pairingActivations = bootstrap.filter(request => request.method === 'POST' + && /\/activate$/u.test(request.url ?? '')); + const intendedPairingModes = ['expiry', 'cancel', 'success']; + const hasExactModes = requests => requests.length === intendedPairingModes.length + && requests.every((request, index) => request.fixtureMode === intendedPairingModes[index]); const authenticatedRest = applicationRequests.filter(request => request.socketIo === false && request.url === '/api/auth/user' @@ -632,6 +656,17 @@ try { pairingBrowserCount: pairingBrowsers.length, pairingPollCount: pairingPolls.length, pairingActivationCount: pairingActivations.length, + pairingMethodBoundaryValid: bootstrap.length === pairingStarts.length + + pairingBrowsers.length + pairingPolls.length + pairingActivations.length, + pairingBrowserCredentialPresent: pairingBrowsers.some(request => + request.credentialHeadersPresent === true), + pairingIntentSequenceValid: hasExactModes(pairingStarts) && hasExactModes(pairingBrowsers), + pairingLifecycleIsolated: pairingBrowsers.every(request => request.fixtureModeStable === true) + && pairingPolls.every(request => request.fixtureMode === 'success') + && pairingActivations.every(request => request.fixtureMode === 'success'), + pairingRequestAfterTerminal: bootstrap.length !== pairingRequestCountAtPairTerminal, + delayedApprovalReadinessProven: process.platform !== 'darwin' + || pairingBrowsers.every(request => request.approvalReadinessDelayed === true), bootstrapAuthorizationPresent: bootstrap.some(request => request.authorization !== null), authenticatedRestCount: authenticatedRest.length, authenticatedSocketCount: socketEvidence.authenticatedSocketCount, diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 2ec7cba6f..e2b3243f3 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -30,6 +30,7 @@ import { openApprovedDesktopPairingUrl } from './pairing-browser'; import { clearPackagedApprovalStorage, createPackagedApprovalNavigation, + createPackagedApprovalTaskTracker, packagedApprovalPartition, } from './packaged-approval-session'; import { createDesktopShutdownCoordinator } from './shutdown'; @@ -604,6 +605,8 @@ const runPackagedConnectJourneySmoke = async ( window: BrowserWindow, profiles: ProfileStore, credentials: DesktopCredentialService, + waitForNextApproval: () => Promise, + waitForApprovalIdle: () => Promise, endpoint: string, phase: 'pair' | 'reprobe', stages: PackagedJourneyStageTracker, @@ -649,11 +652,14 @@ const runPackagedConnectJourneySmoke = async ( } }, ); + await waitForApprovalIdle(); reportPackagedConnectJourneyStage('JOURNEY_NEGATIVE_CANCEL'); await setMode('cancel'); + const approvalReady = waitForNextApproval(); const cancelledPairing = credentials.pair({ id: 'negative-cancel', label: 'Packaged cancel', apiBaseUrl: endpoint, }); + await approvalReady; await new Promise(resolve => setTimeout(resolve, 50)); credentials.cancelPairing('negative-cancel'); await cancelledPairing.then( @@ -664,6 +670,7 @@ const runPackagedConnectJourneySmoke = async ( } }, ); + await waitForApprovalIdle(); reportPackagedConnectJourneyStage('JOURNEY_NEGATIVE_STATE'); const failedProfiles = await profiles.list(); if (failedProfiles.profiles.some(profile => profile.id.startsWith('negative-'))) { @@ -774,6 +781,7 @@ const runPackagedConnectJourneySmoke = async ( || transportEvidence.authenticatedSockets < requiredAuthenticatedRequests) { throw new Error('Packaged Connect authenticated transport proof timed out'); } + await waitForApprovalIdle(); reportPackagedConnectJourneyStage(phase === 'pair' ? 'JOURNEY_PAIR_COMPLETE' : 'JOURNEY_REPROBE_COMPLETE'); @@ -1192,11 +1200,14 @@ if (!hasSingleInstanceLock) { return status; }, }); + const packagedJourneyApprovals = connectSmoke?.journeyEndpoint + ? createPackagedApprovalTaskTracker(openPackagedJourneyApproval) + : null; const credentials = new DesktopCredentialService({ profiles, fetch: session.defaultSession.fetch.bind(session.defaultSession) as typeof globalThis.fetch, - openPairingBrowser: connectSmoke?.journeyEndpoint - ? openPackagedJourneyApproval + openPairingBrowser: packagedJourneyApprovals + ? packagedJourneyApprovals.open : request => openApprovedDesktopPairingUrl(request, shell), clientName: `ProPR Desktop (${process.platform})`, reportRevocationFailure: diagnostic => { @@ -1275,11 +1286,15 @@ if (!hasSingleInstanceLock) { reportPackagedConnectJourneyStage('JOURNEY_DISCOVERY_RENDERER'); const readyFields = await runPackagedConnectDiscoverySmoke(mainWindow); if (connectSmoke.journeyEndpoint && connectSmoke.journeyPhase) { - if (!journeyStages) throw new Error('Packaged Connect journey stage tracker was unavailable'); + if (!journeyStages || !packagedJourneyApprovals) { + throw new Error('Packaged Connect journey stage tracker was unavailable'); + } await runPackagedConnectJourneySmoke( mainWindow, profiles, credentials, + packagedJourneyApprovals.waitForNextOpen, + packagedJourneyApprovals.waitForIdle, connectSmoke.journeyEndpoint, connectSmoke.journeyPhase, journeyStages, diff --git a/apps/desktop/src/packaged-approval-session.test.ts b/apps/desktop/src/packaged-approval-session.test.ts index 152420a51..07de6bc20 100644 --- a/apps/desktop/src/packaged-approval-session.test.ts +++ b/apps/desktop/src/packaged-approval-session.test.ts @@ -5,6 +5,7 @@ import type { BrowserWindow, Session } from 'electron'; import { clearPackagedApprovalStorage, createPackagedApprovalNavigation, + createPackagedApprovalTaskTracker, packagedApprovalPartition, } from './packaged-approval-session'; @@ -204,6 +205,36 @@ describe('packaged pairing approval isolated session', () => { await controller.cleanup(); }); + it('reports owned approval readiness and drains delayed work before the next pairing case', async () => { + const requested: string[] = []; + const releases: Array<() => void> = []; + const tracker = createPackagedApprovalTaskTracker(async request => { + requested.push(request); + await new Promise(resolve => { releases.push(resolve); }); + }); + + for (const request of ['expiry', 'cancel', 'success']) { + let ready = false; + const readiness = tracker.waitForNextOpen().then(() => { ready = true; }); + const concurrentReadiness = tracker.waitForNextOpen(); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(ready, false); + const opened = tracker.open(request); + await Promise.all([readiness, concurrentReadiness]); + assert.equal(ready, true); + let idle = false; + const drained = tracker.waitForIdle().then(() => { idle = true; }); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(idle, false); + assert.equal(requested.filter(value => value === request).length, 1); + releases.shift()?.(); + await Promise.all([opened, drained]); + assert.equal(idle, true); + } + + assert.deepEqual(requested, ['expiry', 'cancel', 'success']); + }); + it('cancels an incidental resource without invalidating the exact main-frame approval', async () => { const value = harness(); const originalLoad = value.window.load; diff --git a/apps/desktop/src/packaged-approval-session.ts b/apps/desktop/src/packaged-approval-session.ts index e53b377cc..230d0283c 100644 --- a/apps/desktop/src/packaged-approval-session.ts +++ b/apps/desktop/src/packaged-approval-session.ts @@ -29,6 +29,12 @@ export interface PackagedApprovalNavigation { cleanup(): Promise; } +export interface PackagedApprovalTaskTracker { + open(request: Request): Promise; + waitForNextOpen(): Promise; + waitForIdle(): Promise; +} + interface PackagedApprovalNavigationOptions { approvalUrl: string; approvalSession: Session; @@ -44,6 +50,55 @@ function cleanupRejected(): Error { return new Error(APPROVAL_CLEANUP_REJECTED); } +/** + * Keep acceptance-only browser work owned after pairing expiry/cancellation. + * The pairing protocol deliberately stops awaiting a browser callback once its + * lifetime ends, but a packaged smoke must still observe the one HTTP approval + * that it started before advancing the shared fixture or publishing READY. + */ +export const createPackagedApprovalTaskTracker = ( + open: (request: Request) => Promise, +): PackagedApprovalTaskTracker => { + const active = new Set>(); + const nextOpenWaiters = new Set<{ after: number; resolve(): void }>(); + let openGeneration = 0; + let rejectedTask = false; + + return { + open(request) { + const task = Promise.resolve().then(() => open(request)); + active.add(task); + openGeneration += 1; + for (const waiter of nextOpenWaiters) { + if (waiter.after >= openGeneration) continue; + nextOpenWaiters.delete(waiter); + waiter.resolve(); + } + void task.then( + () => active.delete(task), + () => { + rejectedTask = true; + active.delete(task); + }, + ); + return task; + }, + waitForNextOpen() { + const after = openGeneration; + return new Promise(resolve => nextOpenWaiters.add({ after, resolve })); + }, + async waitForIdle() { + while (active.size > 0) { + await Promise.all(Array.from(active, task => task.then( + () => undefined, + () => undefined, + ))); + } + if (rejectedTask) throw rejected(); + }, + }; +}; + export const clearPackagedApprovalStorage = async ( approvalSession: Pick, timeoutMs = APPROVAL_CLEANUP_TIMEOUT_MS,