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('
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