From 88ea706c7347a23f5feffb4024e07ea0ce61688c Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:58:10 +0000 Subject: [PATCH 1/9] fix(ai): Resolve issue #2087 - Handle pre-desktop 401 cleanly and prove manual re Implemented by ProPR AI using gpt-5.6-sol model. Implementation completed successfully. --- ...credential-service.pairing-browser.test.ts | 91 +++++++++++++++++-- apps/desktop/src/credential-service.test.ts | 36 ++++++++ apps/desktop/src/credential-service.ts | 12 +++ 3 files changed, 130 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/credential-service.pairing-browser.test.ts b/apps/desktop/src/credential-service.pairing-browser.test.ts index 8e6ae31ea..64f3f1bce 100644 --- a/apps/desktop/src/credential-service.pairing-browser.test.ts +++ b/apps/desktop/src/credential-service.pairing-browser.test.ts @@ -3,6 +3,11 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, it } from 'node:test'; +import { + DESKTOP_TRANSPORT_SCOPE_HEADER, + PROPR_API_COMPATIBILITY, + PROPR_UI_COMPATIBILITY, +} from '@propr/shared'; import { DesktopCredentialService, type DesktopPairingBrowserRequest } from './credential-service'; import { openApprovedDesktopPairingUrl } from './pairing-browser'; import { ProfileStore, type EncryptionProvider } from './profile-store'; @@ -11,6 +16,7 @@ const pairingId = `dpr_${'A'.repeat(22)}`; const pairingNow = Date.parse('2026-01-01T00:00:00.000Z'); const origin = 'https://api.example.test'; const approvalUrl = `${origin}/api/desktop/pairings/${pairingId}/browser`; +const instanceToken = `propr_it_${'T'.repeat(43)}`; const temporaryDirectories: string[] = []; const services: DesktopCredentialService[] = []; @@ -25,8 +31,27 @@ const json = (body: unknown, status = 200): Response => new Response(JSON.string status, headers: { 'Content-Type': 'application/json' }, }); +const discovery = { + product: 'ProPR', + version: '0.8.15', + apiCompatibility: PROPR_API_COMPATIBILITY, + uiCompatibility: PROPR_UI_COMPATIBILITY, + desktopAuthentication: { + protocolVersion: 2 as const, + browserPairing: true, + instanceBearerTokens: true, + socketIoBearerAuthentication: true, + }, +}; + +interface PairingProofOptions { + beforeProvisional?(): void; + onRequest?(request: { url: string; authorization: string | null }): void; +} + const createService = async ( openPairingBrowser: (request: DesktopPairingBrowserRequest) => Promise, + proof: PairingProofOptions = {}, ): Promise => { const directory = await mkdtemp(join(tmpdir(), 'propr-pairing-sink-')); temporaryDirectories.push(directory); @@ -38,6 +63,11 @@ const createService = async ( openPairingBrowser, fetch: async (input, init) => { const url = input.toString(); + proof.onRequest?.({ + url, + authorization: new Headers(init?.headers).get('Authorization'), + }); + if (url === `${origin}/api/desktop/discovery`) return json(discovery); if (url === `${origin}/api/desktop/pairings`) { const request = JSON.parse(String(init?.body)) as Record; binding = { @@ -51,15 +81,22 @@ const createService = async ( expiresAt: new Date(pairingNow + 10_000).toISOString(), interval: 1, }, 201); } - if (url.endsWith('/poll')) return json({ - status: 'provisional', token: `propr_it_${'T'.repeat(43)}`, tokenType: 'Bearer', - activationTicket: 'K'.repeat(43), - activationExpiresAt: new Date(pairingNow + 10_000).toISOString(), ...binding, - }); + if (url.endsWith('/poll')) { + proof.beforeProvisional?.(); + return json({ + status: 'provisional', token: instanceToken, tokenType: 'Bearer', + activationTicket: 'K'.repeat(43), + activationExpiresAt: new Date(pairingNow + 10_000).toISOString(), ...binding, + }); + } if (url.endsWith('/activate')) return json({ status: 'active', receipt: 'R'.repeat(22), activatedAt: '2026-01-01T00:00:01.000Z', expiresAt: null, }); + if (url === `${origin}/api/auth/user`) { + assert.equal(new Headers(init?.headers).get('Authorization'), `Bearer ${instanceToken}`); + return json({ username: 'remote-owner' }); + } throw new Error('Unexpected pairing request'); }, }); @@ -73,14 +110,50 @@ afterEach(async () => { }); describe('DesktopCredentialService pairing browser sink', () => { - it('binds the API base, pairing id, and response URL through the final shell validator', async () => { + it('pairs a manually entered remote through browser approval, persistence, probe, and activation end to end', async () => { const opened: string[] = []; + const requests: Array<{ url: string; authorization: string | null }> = []; + let browserApproved = false; const service = await createService(request => openApprovedDesktopPairingUrl(request, { - openExternal: async url => { opened.push(url); }, - })); + openExternal: async url => { + opened.push(url); + // Models the explicit approval click in the independently authenticated + // system browser. The polling fixture refuses to issue a provisional + // credential until this manual browser step has completed. + browserApproved = true; + }, + }), { + beforeProvisional: () => assert.equal(browserApproved, true), + onRequest: request => requests.push(request), + }); + + const profile = { id: 'profile-a', label: 'Remote ProPR', apiBaseUrl: origin }; + const initialProbe = await service.probe(profile); + assert.equal(initialProbe.status, 'authentication-required'); + const paired = await service.pair(profile); + const probed = await service.probe(profile); + assert.equal(probed.status, 'ready'); + if (probed.status !== 'ready') return; + const activated = await service.activate(probed.activationTicket); - assert.deepEqual(await service.pair({ id: 'profile-a', label: 'A', apiBaseUrl: origin }), { paired: true }); + assert.deepEqual(paired, { paired: true }); assert.deepEqual(opened, [approvalUrl]); + assert.deepEqual(requests.map(request => request.url), [ + `${origin}/api/desktop/discovery`, + `${origin}/api/desktop/pairings`, + `${origin}/api/desktop/pairings/${pairingId}/poll`, + `${origin}/api/desktop/pairings/${pairingId}/activate`, + `${origin}/api/desktop/discovery`, + `${origin}/api/auth/user`, + ]); + assert.deepEqual(requests.map(request => request.authorization), [ + null, null, null, null, null, `Bearer ${instanceToken}`, + ]); + assert.deepEqual(service.prepareRequest( + `${origin}/api/tasks`, + { [DESKTOP_TRANSPORT_SCOPE_HEADER]: activated.transportScope }, + ).requestHeaders, { Authorization: `Bearer ${instanceToken}` }); + assert.equal(JSON.stringify([initialProbe, paired, probed, activated, opened]).includes(instanceToken), false); }); it('rejects a URL replaced after the credential service receives the API response', async () => { diff --git a/apps/desktop/src/credential-service.test.ts b/apps/desktop/src/credential-service.test.ts index 6fb22bf6b..116da5ade 100644 --- a/apps/desktop/src/credential-service.test.ts +++ b/apps/desktop/src/credential-service.test.ts @@ -122,6 +122,42 @@ afterEach(async () => { }); describe('main-process desktop credential service', () => { + it('classifies a pre-desktop discovery 401 as incompatible without attempting authentication', async () => { + const store = await createStore(); + const requests: Array<{ url: string; authorization: string | null }> = []; + const service = createCredentialService({ + profiles: store, + clientName: 'Test desktop', + openPairingBrowser: async () => undefined, + fetch: async (input, init) => { + requests.push({ + url: input.toString(), + authorization: new Headers(init?.headers).get('Authorization'), + }); + return json({ + code: 'AUTHENTICATION_REQUIRED', + error: 'private legacy authentication detail', + }, 401); + }, + }); + + const result = await service.probe({ + id: 'legacy-remote', + label: 'Legacy remote', + apiBaseUrl: 'https://legacy.example.test', + }); + + assert.deepEqual(result, { + status: 'incompatible', + message: 'This instance does not support secure desktop connections. Update ProPR on the instance, then try again.', + }); + assert.deepEqual(requests, [{ + url: 'https://legacy.example.test/api/desktop/discovery', + authorization: null, + }]); + assert.doesNotMatch(JSON.stringify(result), /private legacy authentication detail|AUTHENTICATION_REQUIRED/); + }); + it('injects the active bearer only for its bound profile origin and strips renderer identity', async () => { const store = await createStore(); const profile = await store.save({ id: 'profile-a', label: 'A', apiBaseUrl: 'https://a.example.test' }); diff --git a/apps/desktop/src/credential-service.ts b/apps/desktop/src/credential-service.ts index ba80ffc2d..1a58e38f6 100644 --- a/apps/desktop/src/credential-service.ts +++ b/apps/desktop/src/credential-service.ts @@ -680,6 +680,18 @@ export class DesktopCredentialService { try { discovery = await discoveryClient.discoverDesktop(8_000, operation.signal); } catch (error) { + // The generic auth guard in releases that predate desktop discovery + // answers an unknown /api/desktop/discovery route with 401. Signing in + // cannot make those releases pairable: discovery and pairing bootstrap + // must both be public protocol endpoints. Classify that stable legacy + // response as incompatible instead of presenting a transient outage or + // sending the user into an authentication loop. + if (error instanceof ProprClientError && error.kind === 'http' && error.status === 401) { + return { + status: 'incompatible', + message: 'This instance does not support secure desktop connections. Update ProPR on the instance, then try again.', + }; + } return { status: 'offline', message: error instanceof Error From c428d8fca4c6605bcbaa81ac650c5c732332c243 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:40:38 +0000 Subject: [PATCH 2/9] =?UTF-8?q?feat(ai):=20Implemented=20F1=E2=80=93F3=20o?= =?UTF-8?q?nly,=20without=20committing=20or=20merging.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented F1–F3 only, without committing or merging. - F1: Added real Express HTTP route-order integration through the production desktop API registrar. Public discovery/pairing routes bypass authentication; `/api/status` returns `401`. - F2: Extended the existing macOS/Linux packaged Electron harness through manual URL entry, strict discovery, browser approval, pairing, OS-backed persistence/restart, scoped REST and Socket.IO, dashboard connected state, expiry/cancellation, malformed/oversized discovery, secret checks, and stale-scope rejection. - F3: Added strict shared discovery parsing and a narrow typed signal for the exact credential-free JSON discovery `401`. HTML/policy responses and operational `401`s remain strict errors. Verification: - Desktop: 360 passed, 25 platform skips. - Client: 70/70 passed. - Native durability: exact 116/116; categories 69 + 37 + 10. - API integration, API lint/typecheck, shared and UI typechecks passed. - Root unit suite: 284/284 passed. - `git diff --check` passed. No workflow, lockfile, Windows-specific file, signing/publishing, commit, or merge changes were made. The four target-native packaged lanes were not runnable locally because packaged macOS/Linux artifacts were unavailable; the harness is wired for those existing CI lanes. PR: #2089 Comment by: @propr-dev[bot] (ID: 5516412358) Model: gpt-5.6-sol --- .../desktop/scripts/run-native-durability.mjs | 2 +- .../scripts/smoke-packaged-connect.mjs | 340 +++++++++++++++++- ...credential-service.pairing-browser.test.ts | 3 + apps/desktop/src/credential-service.test.ts | 5 +- apps/desktop/src/credential-service.ts | 16 +- apps/desktop/src/main.ts | 210 ++++++++++- package.json | 2 +- packages/api/desktopApiBoundary.ts | 39 ++ packages/api/server.ts | 26 +- packages/api/test/desktopApiBoundary.test.ts | 69 ++++ packages/client/src/client.ts | 92 ++++- packages/client/src/errors.ts | 4 + packages/client/src/index.ts | 1 + packages/client/test/desktopPairing.test.ts | 81 ++++- packages/shared/src/connectDiscovery.ts | 85 +++++ packages/shared/src/index.ts | 1 + 16 files changed, 926 insertions(+), 50 deletions(-) create mode 100644 packages/api/desktopApiBoundary.ts create mode 100644 packages/api/test/desktopApiBoundary.test.ts diff --git a/apps/desktop/scripts/run-native-durability.mjs b/apps/desktop/scripts/run-native-durability.mjs index db9aab001..568106ddc 100644 --- a/apps/desktop/scripts/run-native-durability.mjs +++ b/apps/desktop/scripts/run-native-durability.mjs @@ -2,7 +2,7 @@ import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; const EXPECTED = Object.freeze({ - 'credential-service': 68, + 'credential-service': 69, 'profile-store': 37, 'pairing-shutdown': 10, }); diff --git a/apps/desktop/scripts/smoke-packaged-connect.mjs b/apps/desktop/scripts/smoke-packaged-connect.mjs index a8b8493be..cb1209eb7 100644 --- a/apps/desktop/scripts/smoke-packaged-connect.mjs +++ b/apps/desktop/scripts/smoke-packaged-connect.mjs @@ -1,10 +1,20 @@ import { spawn, spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; +import { once } from 'node:events'; import { - chmod, lstat, mkdir, mkdtemp, readFile, realpath, writeFile, + chmod, lstat, mkdir, mkdtemp, readFile, readdir, realpath, writeFile, } from 'node:fs/promises'; import { tmpdir } from 'node:os'; +import { createServer } from 'node:http'; import { basename, dirname, join, relative, resolve } from 'node:path'; +import { Server as SocketIOServer } from 'socket.io'; +import { + DESKTOP_RENDERER_ORIGIN, + DESKTOP_TRANSPORT_SCOPE_HEADER, + DESKTOP_TRANSPORT_SCOPE_QUERY, + PROPR_API_COMPATIBILITY, + PROPR_UI_COMPATIBILITY, +} from '@propr/shared'; import { preservePrimaryWithCleanup, removeAuthorizedConnectFixture, @@ -67,6 +77,250 @@ let packagedConnectPhase = 'fixture-setup'; let windowsStagedContract; let windowsStagedHandoff; +const createPackagedJourneyFixture = async () => { + const pairingId = `dpr_${'P'.repeat(22)}`; + const deviceSecret = 'D'.repeat(43); + const activationTicket = 'A'.repeat(43); + const token = `propr_it_${'T'.repeat(43)}`; + const receipt = 'R'.repeat(22); + const requests = []; + let endpoint; + let approved = false; + let active = false; + let binding; + let mode = 'success'; + const cors = { + 'Access-Control-Allow-Credentials': 'true', + 'Access-Control-Allow-Headers': 'Authorization, Content-Type, X-ProPR-Desktop-Transport-Scope', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Origin': DESKTOP_RENDERER_ORIGIN, + 'Access-Control-Allow-Private-Network': 'true', + 'Cache-Control': 'no-store', + 'Content-Type': 'application/json', + }; + const readJson = request => new Promise((resolveBody, rejectBody) => { + const chunks = []; + let bytes = 0; + request.on('data', chunk => { + bytes += chunk.length; + if (bytes > 16 * 1024) { + rejectBody(new Error('oversized request')); + request.destroy(); + } else chunks.push(chunk); + }); + request.on('end', () => { + try { resolveBody(JSON.parse(Buffer.concat(chunks).toString('utf8'))); } + catch (error) { rejectBody(error); } + }); + request.on('error', rejectBody); + }); + const server = createServer(async (request, response) => { + const record = { + method: request.method, + url: request.url, + authorization: request.headers.authorization ?? null, + origin: request.headers.origin ?? null, + transportScope: request.headers[DESKTOP_TRANSPORT_SCOPE_HEADER.toLowerCase()] ?? null, + socketIo: false, + }; + requests.push(record); + if (request.method === 'OPTIONS') { + response.writeHead(204, cors); + response.end(); + return; + } + try { + if (request.method === 'POST' && request.url?.startsWith('/__packaged/control/')) { + const requestedMode = request.url.slice('/__packaged/control/'.length); + if (!['success', 'malformed', 'oversized', 'expiry', 'cancel'].includes(requestedMode)) { + throw new Error('invalid fixture mode'); + } + mode = requestedMode; + approved = false; + binding = undefined; + response.writeHead(204, cors); + response.end(); + return; + } + if (request.method === 'GET' && request.url === '/__packaged/evidence') { + const authenticatedRest = requests.filter(item => item.socketIo === false + && item.url === '/api/auth/user' + && item.authorization === `Bearer ${token}` + && typeof item.transportScope === 'string'); + const authenticatedSockets = requests.filter(item => item.socketIo === true + && item.authorization === `Bearer ${token}`); + response.writeHead(200, cors); + response.end(JSON.stringify({ + authenticatedRest: authenticatedRest.length, + authenticatedSockets: authenticatedSockets.length, + })); + return; + } + if (request.method === 'GET' && request.url === '/api/desktop/discovery') { + response.writeHead(200, cors); + if (mode === 'malformed') { + response.end('{"product":"ProPR"}'); + return; + } + if (mode === 'oversized') { + response.end(`{"ignored":"${'x'.repeat(9 * 1024)}"}`); + return; + } + response.end(JSON.stringify({ + schemaVersion: 1, + product: 'ProPR', + version: '0.8.15', + apiCompatibility: PROPR_API_COMPATIBILITY, + uiCompatibility: PROPR_UI_COMPATIBILITY, + canonicalEndpoint: null, + publicInstanceIdentity: identity, + desktopAuthentication: { + protocolVersion: 2, + browserPairing: true, + instanceBearerTokens: true, + socketIoBearerAuthentication: true, + }, + })); + return; + } + if (request.method === 'POST' && request.url === '/api/desktop/pairings') { + binding = await readJson(request); + response.writeHead(201, cors); + response.end(JSON.stringify({ + pairingId, + deviceSecret, + approvalUrl: `${endpoint}/api/desktop/pairings/${pairingId}/browser`, + expiresAt: new Date(Date.now() + (mode === 'expiry' ? 200 : 60_000)).toISOString(), + interval: 1, + })); + return; + } + if (request.method === 'GET' && request.url === `/api/desktop/pairings/${pairingId}/browser`) { + approved = true; + response.writeHead(200, { 'Cache-Control': 'no-store', 'Content-Type': 'text/html' }); + response.end('Desktop approved

Approved

'); + return; + } + if (request.method === 'POST' && request.url === `/api/desktop/pairings/${pairingId}/poll`) { + const body = await readJson(request); + if (body.deviceSecret !== deviceSecret || !approved || !binding) throw new Error('pairing not approved'); + if (mode === 'cancel' || mode === 'expiry') { + response.writeHead(202, cors); + response.end('{"status":"pending","interval":1}'); + return; + } + response.writeHead(200, cors); + response.end(JSON.stringify({ + status: 'provisional', token, tokenType: 'Bearer', activationTicket, + activationExpiresAt: new Date(Date.now() + 60_000).toISOString(), + instanceId: binding.instanceId, + origin: binding.origin, + scope: binding.scope, + credentialGeneration: binding.credentialGeneration, + })); + return; + } + if (request.method === 'POST' && request.url === `/api/desktop/pairings/${pairingId}/activate`) { + const body = await readJson(request); + if (body.deviceSecret !== deviceSecret || body.activationTicket !== activationTicket) { + throw new Error('activation binding rejected'); + } + active = true; + response.writeHead(200, cors); + response.end(JSON.stringify({ + status: 'active', receipt, activatedAt: new Date().toISOString(), expiresAt: null, + })); + return; + } + if (request.method === 'DELETE' && request.url === '/api/desktop/tokens/current') { + active = false; + response.writeHead(204, cors); + response.end(); + return; + } + if (request.method === 'GET' && request.url === '/api/auth/user' + && active && record.authorization === `Bearer ${token}`) { + response.writeHead(200, cors); + response.end(JSON.stringify({ + id: 'packaged-owner', login: 'packaged-owner', username: 'packaged-owner', + displayName: 'Packaged Owner', email: null, avatarUrl: null, + role: 'admin', permissions: [], authorizationSource: 'bootstrap', + })); + return; + } + if (request.method === 'GET' && record.authorization === `Bearer ${token}`) { + response.writeHead(200, cors); + response.end('{}'); + return; + } + } catch { + response.writeHead(400, cors); + response.end('{"code":"INVALID_SMOKE_REQUEST"}'); + return; + } + response.writeHead(401, cors); + response.end('{"code":"INVALID_INSTANCE_TOKEN"}'); + }); + const io = new SocketIOServer(server, { + path: '/socket.io/', + transports: ['websocket'], + cors: { origin: DESKTOP_RENDERER_ORIGIN, credentials: false }, + }); + io.of('/').use((socket, next) => { + const scopes = new URL(socket.handshake.url, 'http://fixture.invalid') + .searchParams.getAll(DESKTOP_TRANSPORT_SCOPE_QUERY); + requests.push({ + method: 'SOCKET.IO', + url: socket.handshake.url, + authorization: socket.handshake.headers.authorization ?? null, + origin: socket.handshake.headers.origin ?? null, + transportScope: scopes[0] ?? null, + socketIo: true, + }); + if (!active || socket.handshake.headers.authorization !== `Bearer ${token}` + || scopes.length !== 1 || socket.handshake.auth?.[DESKTOP_TRANSPORT_SCOPE_QUERY] !== scopes[0]) { + const error = new Error('INVALID_INSTANCE_TOKEN'); + error.data = { code: 'INVALID_INSTANCE_TOKEN' }; + next(error); + return; + } + next(); + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Packaged journey fixture did not bind'); + endpoint = `http://127.0.0.1:${address.port}`; + return { + endpoint, + requests, + secrets: [deviceSecret, activationTicket, token], + async close() { + await io.close(); + await new Promise((resolveClose, rejectClose) => { + server.close(error => error ? rejectClose(error) : resolveClose()); + }); + }, + }; +}; + +const directoryContainsPlaintext = async (root, needles) => { + const visit = async path => { + const entries = await readdir(path, { withFileTypes: true }); + for (const entry of entries) { + const child = join(path, entry.name); + if (entry.isDirectory()) { + if (await visit(child)) return true; + } else if (entry.isFile()) { + const contents = await readFile(child); + if (needles.some(needle => contents.includes(Buffer.from(needle)))) return true; + } + } + return false; + }; + return visit(root); +}; + if (process.platform === 'win32') { try { packagedConnectPhase = 'staged-contract'; @@ -219,6 +473,7 @@ const protectWindowsEntries = entries => { let canonicalTemp; let fixture; let generatedFixtureLeaf; +let journeyFixture; let outcome = { ok: false, category: 'fixture-setup', capture: 'complete', records: [] }; let failurePhase = 'fixture-setup'; try { @@ -263,11 +518,13 @@ try { || relative(canonicalTemp, configRoot) !== join(generatedFixtureLeaf, 'config')) { throw new Error('Connect smoke fixture escaped its fixed root'); } + if (process.platform !== 'win32') journeyFixture = await createPackagedJourneyFixture(); failurePhase = 'package-validation'; await assertPackageAuthority(); const treeKillerPath = await windowsTreeKiller(); const sensitiveNeedles = [ ...secrets, fixture, configRoot, stackRoot, identity, + ...(journeyFixture?.secrets ?? []), ...packagedConnectArtifactSensitiveNeedles({ platform: process.platform, artifactRoot, @@ -284,6 +541,9 @@ try { PROPR_CONNECTOR_TOKEN: secrets[1], PROPR_RELAY_TOKEN: secrets[2], GITHUB_TOKEN: secrets[3], + ...(journeyFixture ? { + PROPR_DESKTOP_CONNECT_JOURNEY_ENDPOINT: journeyFixture.endpoint, + } : {}), }; delete childEnvironment.PROPR_DESKTOP_CONNECT_STAGING_PARENT; delete childEnvironment.PROPR_DESKTOP_CONNECT_STAGING_LEAF; @@ -291,32 +551,84 @@ try { if (executable !== binaryPath) return spawn(executable, args, options); const child = spawn(binaryPath, ['--disable-gpu', `--user-data-dir=${userDataPath}`], { ...options, - env: childEnvironment, + env: options.env, }); return child; }; failurePhase = 'lifecycle-internal'; - outcome = await runPackagedConnectLifecycle({ - binaryPath, - args: ['--disable-gpu', `--user-data-dir=${userDataPath}`], - platform: process.platform, - arch: process.arch, - authorityMechanism: authorityMechanism(), - sensitiveNeedles, - treeKillerPath, - env: childEnvironment, - spawn: spawnLifecycleProcess, - }); + const runPhase = async phase => await runPackagedConnectLifecycle({ + binaryPath, + args: ['--disable-gpu', `--user-data-dir=${userDataPath}`], + platform: process.platform, + arch: process.arch, + authorityMechanism: authorityMechanism(), + sensitiveNeedles, + treeKillerPath, + env: { + ...childEnvironment, + ...(journeyFixture ? { PROPR_DESKTOP_CONNECT_JOURNEY_PHASE: phase } : {}), + }, + spawn: spawnLifecycleProcess, + }); + outcome = await runPhase('pair'); + if (outcome.ok && journeyFixture) { + outcome = await runPhase('reprobe'); + if (outcome.ok) { + const applicationRequests = journeyFixture.requests.filter(request => request.method !== 'OPTIONS'); + const discoveries = applicationRequests.filter(request => request.url === '/api/desktop/discovery'); + const bootstrap = applicationRequests.filter(request => + 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 pairingActivations = bootstrap.filter(request => /\/activate$/u.test(request.url ?? '')); + const authenticatedRest = applicationRequests.filter(request => + request.socketIo === false + && request.url === '/api/auth/user' + && request.authorization === `Bearer ${journeyFixture.secrets[2]}`); + const authenticatedSockets = applicationRequests.filter(request => + request.socketIo === true && request.authorization === `Bearer ${journeyFixture.secrets[2]}`); + const socketScopes = new Set(authenticatedSockets.map(request => + new URL(request.url, 'http://fixture.invalid').searchParams.get(DESKTOP_TRANSPORT_SCOPE_QUERY))); + const restScopes = new Set(authenticatedRest.map(request => request.transportScope)); + const firstBearer = applicationRequests.findIndex(request => request.authorization !== null); + const firstIdentity = applicationRequests.findIndex(request => request.url === '/api/desktop/discovery'); + const plaintextPersisted = await directoryContainsPlaintext(userDataPath, journeyFixture.secrets); + if (discoveries.length !== 5 + || pairingStarts.length !== 3 + || pairingBrowsers.length !== 3 + || pairingActivations.length !== 1 + || bootstrap.some(request => request.authorization !== null) + || authenticatedRest.length < 2 + || authenticatedSockets.length < 2 + || restScopes.has(null) + || restScopes.size < 2 + || socketScopes.has(null) + || socketScopes.size < 2 + || [...restScopes].some(scope => !socketScopes.has(scope)) + || plaintextPersisted + || firstIdentity < 0 + || firstBearer <= firstIdentity) { + outcome = { ok: false, category: 'journey-evidence', capture: 'complete', records: [] }; + } + } + } } catch { outcome = { ok: false, category: failurePhase, capture: 'complete', records: [] }; } finally { let cleanup = { ok: true }; + if (journeyFixture) { + try { await journeyFixture.close(); } + catch { cleanup = { ok: false, category: 'fixture-cleanup-failed' }; } + } if (fixture && canonicalTemp && generatedFixtureLeaf) { - cleanup = await removeAuthorizedConnectFixture({ + const directoryCleanup = await removeAuthorizedConnectFixture({ fixture, canonicalTemporaryParent: canonicalTemp, generatedLeaf: generatedFixtureLeaf, }); + if (!directoryCleanup.ok) cleanup = directoryCleanup; } if (!cleanup.ok) { outcome = preservePrimaryWithCleanup(outcome, cleanup); diff --git a/apps/desktop/src/credential-service.pairing-browser.test.ts b/apps/desktop/src/credential-service.pairing-browser.test.ts index 64f3f1bce..7ca3ccfb7 100644 --- a/apps/desktop/src/credential-service.pairing-browser.test.ts +++ b/apps/desktop/src/credential-service.pairing-browser.test.ts @@ -32,10 +32,13 @@ const json = (body: unknown, status = 200): Response => new Response(JSON.string }); const discovery = { + schemaVersion: 1 as const, product: 'ProPR', version: '0.8.15', apiCompatibility: PROPR_API_COMPATIBILITY, uiCompatibility: PROPR_UI_COMPATIBILITY, + canonicalEndpoint: null, + publicInstanceIdentity: '123e4567-e89b-42d3-a456-426614174000', desktopAuthentication: { protocolVersion: 2 as const, browserPairing: true, diff --git a/apps/desktop/src/credential-service.test.ts b/apps/desktop/src/credential-service.test.ts index 116da5ade..3088d524b 100644 --- a/apps/desktop/src/credential-service.test.ts +++ b/apps/desktop/src/credential-service.test.ts @@ -74,10 +74,13 @@ const terminalRevocation = ( code: 'TOKEN_NOT_FOUND' | 'INSTANCE_TOKEN_REVOKED' | 'INSTANCE_TOKEN_EXPIRED' = 'TOKEN_NOT_FOUND', ): Response => json(terminalRevocationBody(init, code), code === 'TOKEN_NOT_FOUND' ? 404 : 401); const discovery = { + schemaVersion: 1 as const, product: 'ProPR', version: '0.8.15', apiCompatibility: PROPR_API_COMPATIBILITY, uiCompatibility: PROPR_UI_COMPATIBILITY, + canonicalEndpoint: null, + publicInstanceIdentity: '123e4567-e89b-42d3-a456-426614174000', desktopAuthentication: { protocolVersion: 2 as const, browserPairing: true, @@ -149,7 +152,7 @@ describe('main-process desktop credential service', () => { assert.deepEqual(result, { status: 'incompatible', - message: 'This instance does not support secure desktop connections. Update ProPR on the instance, then try again.', + message: 'This instance requires authentication for public desktop discovery. Check its proxy configuration or update ProPR, then try again.', }); assert.deepEqual(requests, [{ url: 'https://legacy.example.test/api/desktop/discovery', diff --git a/apps/desktop/src/credential-service.ts b/apps/desktop/src/credential-service.ts index 1a58e38f6..ac2f86514 100644 --- a/apps/desktop/src/credential-service.ts +++ b/apps/desktop/src/credential-service.ts @@ -1,5 +1,6 @@ import { randomBytes } from 'node:crypto'; import { + DESKTOP_DISCOVERY_AUTHENTICATION_REQUIRED, ProprClient, ProprClientError, type PairingProtocolRequestOptions, @@ -680,16 +681,15 @@ export class DesktopCredentialService { try { discovery = await discoveryClient.discoverDesktop(8_000, operation.signal); } catch (error) { - // The generic auth guard in releases that predate desktop discovery - // answers an unknown /api/desktop/discovery route with 401. Signing in - // cannot make those releases pairable: discovery and pairing bootstrap - // must both be public protocol endpoints. Classify that stable legacy - // response as incompatible instead of presenting a transient outage or - // sending the user into an authentication loop. - if (error instanceof ProprClientError && error.kind === 'http' && error.status === 401) { + // Only the client's typed signal for the exact credential-free public + // discovery request is actionable here. Generic HTTP 401s, malformed + // identity, redirects, and authenticated operation failures stay strict. + if (error instanceof ProprClientError + && error.kind === 'invalid_response' + && error.code === DESKTOP_DISCOVERY_AUTHENTICATION_REQUIRED) { return { status: 'incompatible', - message: 'This instance does not support secure desktop connections. Update ProPR on the instance, then try again.', + message: 'This instance requires authentication for public desktop discovery. Check its proxy configuration or update ProPR, then try again.', }; } return { diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 0d105b1e9..4d0771614 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -17,7 +17,7 @@ import { import { DesktopConnectDiscoveryService } from './connect-discovery'; import { DeepLinkDelivery } from './deep-link-delivery'; import { clearDesktopInstanceCookies } from './desktop-session'; -import { DesktopCredentialService } from './credential-service'; +import { DesktopCredentialService, type DesktopPairingBrowserRequest } from './credential-service'; import { registerIpcHandlers } from './ipc'; import { LocalLifecycleController } from './lifecycle'; import { createDesktopLogger, type DesktopLogger } from './logger'; @@ -97,6 +97,8 @@ let activePackagedTransportSmoke: PackagedTransportSmoke | null = null; interface PackagedConnectSmoke { configRoot: string; fetch: typeof globalThis.fetch; + journeyEndpoint?: string; + journeyPhase?: 'pair' | 'reprobe'; } const packagedConnectSmoke = (): PackagedConnectSmoke | null => { @@ -109,6 +111,21 @@ const packagedConnectSmoke = (): PackagedConnectSmoke | null => { if (!contained || contained.startsWith('..') || isAbsolute(contained)) { throw new Error('Packaged Connect smoke config root is outside the temporary directory'); } + const suppliedJourneyEndpoint = process.env.PROPR_DESKTOP_CONNECT_JOURNEY_ENDPOINT; + const suppliedJourneyPhase = process.env.PROPR_DESKTOP_CONNECT_JOURNEY_PHASE; + let journeyEndpoint: string | undefined; + let journeyPhase: 'pair' | 'reprobe' | undefined; + if (suppliedJourneyEndpoint !== undefined || suppliedJourneyPhase !== undefined) { + const normalized = normalizeApiBaseUrl(suppliedJourneyEndpoint ?? ''); + if (!normalized) throw new Error('Packaged Connect journey requires a bounded non-Windows loopback fixture'); + const parsed = new URL(normalized); + if (process.platform === 'win32' || parsed.protocol !== 'http:' || parsed.hostname !== '127.0.0.1' + || (suppliedJourneyPhase !== 'pair' && suppliedJourneyPhase !== 'reprobe')) { + throw new Error('Packaged Connect journey requires a bounded non-Windows loopback fixture'); + } + journeyEndpoint = normalized; + journeyPhase = suppliedJourneyPhase; + } const endpoint = 'https://t-packaged123.propr.dev'; const publicInstanceIdentity = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; const fetch: typeof globalThis.fetch = async input => { @@ -131,7 +148,7 @@ const packagedConnectSmoke = (): PackagedConnectSmoke | null => { }, }), { status: 200, headers: { 'Content-Type': 'application/json' } }); }; - return { configRoot, fetch }; + return { configRoot, fetch, journeyEndpoint, journeyPhase }; }; const packagedTransportSmoke = (): PackagedTransportSmoke | null => { @@ -381,7 +398,12 @@ const inspectPackagedReducedNativeWindow = (): Record => { } }; -const runPackagedConnectDiscoverySmoke = async (window: BrowserWindow): Promise => { +const runPackagedConnectDiscoverySmoke = async (window: BrowserWindow): Promise<{ + selectedPlatform: string; + selectedArch: string; + authorityMechanism: string; + rendererSchemaValid: true; +}> => { const proof = await window.webContents.executeJavaScript(`(async () => { const bridge = window.proprDesktop; const metadata = await bridge.app.getMetadata(); @@ -413,6 +435,12 @@ const runPackagedConnectDiscoverySmoke = async (window: BrowserWindow): Promise< rendererSchemaValid: true, } as const; log('info', 'desktop.renderer.connect_discovery.ready', readyFields); + return readyFields; +}; + +const publishPackagedConnectReady = async (readyFields: Awaited< + ReturnType +>): Promise => { await new Promise((resolveReady, rejectReady) => { process.stdout.write(`${JSON.stringify({ timestamp: new Date().toISOString(), @@ -426,6 +454,166 @@ const runPackagedConnectDiscoverySmoke = async (window: BrowserWindow): Promise< }); }; +const openPackagedJourneyApproval = async (request: DesktopPairingBrowserRequest): Promise => { + await openApprovedDesktopPairingUrl(request, { + openExternal: async url => { + const approvalWindow = new BrowserWindow({ + show: false, + webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, webSecurity: true }, + }); + try { + await approvalWindow.loadURL(url); + } finally { + if (!approvalWindow.isDestroyed()) approvalWindow.destroy(); + } + }, + }); +}; + +const runPackagedConnectJourneySmoke = async ( + window: BrowserWindow, + profiles: ProfileStore, + credentials: DesktopCredentialService, + endpoint: string, + phase: 'pair' | 'reprobe', +): Promise => { + const security = profiles.security(); + if (!security.available || security.backend === 'basic_text') { + throw new Error('Packaged Connect journey requires the production OS credential backend'); + } + if (phase === 'pair') { + const setMode = async (mode: 'success' | 'malformed' | 'oversized' | 'expiry' | 'cancel') => { + const response = await session.defaultSession.fetch(`${endpoint}/__packaged/control/${mode}`, { + method: 'POST', redirect: 'manual', + }); + if (response.status !== 204) throw new Error('Packaged Connect fixture control failed'); + }; + for (const mode of ['malformed', 'oversized'] as const) { + await setMode(mode); + const result = await credentials.probe({ + id: `negative-${mode}`, + label: `Packaged ${mode}`, + apiBaseUrl: endpoint, + }); + if (result.status === 'ready' || result.status === 'incompatible') { + throw new Error('Strict packaged discovery accepted invalid identity'); + } + } + await setMode('expiry'); + await credentials.pair({ + id: 'negative-expiry', label: 'Packaged expiry', apiBaseUrl: endpoint, + }).then( + () => { throw new Error('Packaged pairing expiry unexpectedly succeeded'); }, + error => { + if (!(error instanceof Error) || !/expired/i.test(error.message)) { + throw new Error('Packaged pairing expiry classification failed'); + } + }, + ); + await setMode('cancel'); + const cancelledPairing = credentials.pair({ + id: 'negative-cancel', label: 'Packaged cancel', apiBaseUrl: endpoint, + }); + await new Promise(resolve => setTimeout(resolve, 50)); + credentials.cancelPairing('negative-cancel'); + await cancelledPairing.then( + () => { throw new Error('Packaged pairing cancellation unexpectedly succeeded'); }, + error => { + if (!(error instanceof Error) || !/cancelled/i.test(error.message)) { + throw new Error('Packaged pairing cancellation classification failed'); + } + }, + ); + const failedProfiles = await profiles.list(); + if (failedProfiles.profiles.some(profile => profile.id.startsWith('negative-'))) { + throw new Error('Failed packaged pairing left stale profile or credential state'); + } + await setMode('success'); + } + const proof = await window.webContents.executeJavaScript(`(async () => { + const waitFor = async predicate => { + const deadline = performance.now() + 15000; + do { + const value = predicate(); + if (value) return value; + await new Promise(resolve => setTimeout(resolve, 25)); + } while (performance.now() < deadline); + throw new Error('Packaged Connect journey renderer state timed out'); + }; + const setInput = (input, value) => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + setter.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); + }; + if (${JSON.stringify(phase)} === 'pair') { + const chooser = await waitFor(() => document.querySelector('.desktop-welcome-card')); + const connect = Array.from(chooser.querySelectorAll('button.desktop-choice-button')) + .find(button => button.textContent?.includes('Connect to an existing instance')); + if (!(connect instanceof HTMLButtonElement)) throw new Error('Manual connection action was missing'); + connect.click(); + const form = await waitFor(() => document.querySelector('form.desktop-profile-form')); + const inputs = form.querySelectorAll('input'); + if (inputs.length !== 2) throw new Error('Manual connection form was incomplete'); + setInput(inputs[0], 'Packaged remote'); + setInput(inputs[1], ${JSON.stringify(endpoint)}); + form.requestSubmit(); + const authenticate = await waitFor(() => Array.from(document.querySelectorAll('.desktop-connection-card button')) + .find(button => button.textContent?.includes('Sign in in browser'))); + authenticate.click(); + } + const dashboard = await waitFor(() => document.querySelector('.desktop-app')); + const connection = await waitFor(() => document.querySelector('.desktop-connection-pill.desktop-connection-ready')); + await waitFor(() => document.querySelector('.desktop-titlebar')); + return { + connected: dashboard instanceof HTMLElement && connection instanceof HTMLButtonElement, + rendererContractsContainSecret: JSON.stringify([window.proprDesktop, dashboard.dataset]).includes('propr_it_'), + title: connection.getAttribute('aria-label'), + }; + })()`); + if (proof?.connected !== true || proof?.rendererContractsContainSecret !== false + || !proof?.title?.startsWith('Connected: Packaged remote')) { + throw new Error('Packaged Connect dashboard did not reach its connected state'); + } + const requiredAuthenticatedRequests = phase === 'pair' ? 1 : 2; + const evidenceDeadline = Date.now() + 10_000; + let transportEvidence = { authenticatedRest: 0, authenticatedSockets: 0 }; + do { + const response = await session.defaultSession.fetch(`${endpoint}/__packaged/evidence`, { + credentials: 'omit', + redirect: 'manual', + }); + if (response.status !== 200) throw new Error('Packaged Connect transport evidence was unavailable'); + const candidate: unknown = await response.json(); + if (candidate !== null && typeof candidate === 'object') { + const record = candidate as Record; + if (Number.isInteger(record.authenticatedRest) && Number.isInteger(record.authenticatedSockets)) { + transportEvidence = { + authenticatedRest: record.authenticatedRest as number, + authenticatedSockets: record.authenticatedSockets as number, + }; + } + } + if (transportEvidence.authenticatedRest >= requiredAuthenticatedRequests + && transportEvidence.authenticatedSockets >= requiredAuthenticatedRequests) break; + await new Promise(resolve => setTimeout(resolve, 25)); + } while (Date.now() < evidenceDeadline); + if (transportEvidence.authenticatedRest < requiredAuthenticatedRequests + || transportEvidence.authenticatedSockets < requiredAuthenticatedRequests) { + throw new Error('Packaged Connect authenticated transport proof timed out'); + } + log('info', 'desktop.renderer.connect_journey.ready', { + phase, + storageBackend: security.backend, + manualUrl: phase === 'pair', + publicDiscovery: true, + browserApproval: phase === 'pair', + persistedReprobe: phase === 'reprobe', + restBearer: true, + socketIo: true, + dashboardConnected: true, + }); +}; + const runPackagedTransportSmoke = async ( window: BrowserWindow, profiles: ProfileStore, @@ -828,7 +1016,9 @@ if (!hasSingleInstanceLock) { const credentials = new DesktopCredentialService({ profiles, fetch: session.defaultSession.fetch.bind(session.defaultSession) as typeof globalThis.fetch, - openPairingBrowser: request => openApprovedDesktopPairingUrl(request, shell), + openPairingBrowser: connectSmoke?.journeyEndpoint + ? openPackagedJourneyApproval + : request => openApprovedDesktopPairingUrl(request, shell), clientName: `ProPR Desktop (${process.platform})`, reportRevocationFailure: diagnostic => { log('warn', 'desktop.credential_revocation.retry_pending', diagnostic); @@ -875,7 +1065,17 @@ if (!hasSingleInstanceLock) { mainWindow = await createMainWindow(); if (connectSmoke) { - await runPackagedConnectDiscoverySmoke(mainWindow); + const readyFields = await runPackagedConnectDiscoverySmoke(mainWindow); + if (connectSmoke.journeyEndpoint && connectSmoke.journeyPhase) { + await runPackagedConnectJourneySmoke( + mainWindow, + profiles, + credentials, + connectSmoke.journeyEndpoint, + connectSmoke.journeyPhase, + ); + } + await publishPackagedConnectReady(readyFields); app.quit(); } else if (transportSmoke) { await runPackagedTransportSmoke(mainWindow, profiles, credentials, transportSmoke); diff --git a/package.json b/package.json index 4f71e66ef..4872ae6a5 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "test:notifications:ui": "npm --workspace propr-ui test -- src/api/notificationApi.test.ts src/serviceWorker.test.ts src/serviceWorkerRegistration.test.ts src/hooks/useBrowserPush.test.tsx src/pages/SettingsPage/NotificationSettingsSection.test.tsx src/pages/InboxPage.test.tsx src/pages/inboxUtils.test.ts src/components/Inbox/NotificationActions.test.tsx src/components/MobileBottomNavigation.test.tsx src/contexts/NotificationCenterContext.test.tsx src/utils/notificationIntents.test.ts src/pages/PlanStudioPage.notificationIntent.test.tsx src/components/TaskPlanner/PlanEditor.notificationIntent.test.tsx src/components/TaskPlanner/PlanIssuesManager.notificationIntent.test.tsx src/components/TaskPlanner/PlanEditor.responsive.test.tsx", "test:notifications": "npm run build -w @propr/shared && npm run build -w @propr/core && npm run test:notifications:server && npm run test:notifications:ui", "pretest:unit": "npm run build -w @propr/shared && npm run build -w @propr/local-setup", - "test:unit": "NODE_ENV=test npx tsx --experimental-test-module-mocks --test test/minimal.test.ts test/modelName.test.ts test/agentContainerResources.test.ts test/agentDockerfileSupplyChain.test.ts test/daemonEventIntake.test.ts test/databaseMigrationGate.test.ts test/generateContext.test.ts test/githubEventIntakeMode.test.ts test/intakeModePrerequisites.test.ts test/orchestratorMigrationPhase.test.mjs test/validateRoutingUrl.test.ts test/routingWebSocketProtocol.test.ts test/routingWebSocketIntakeService.test.ts test/routingStatusPublisher.test.ts test/releaseValidation.test.mjs test/sessionSecret.test.ts test/testSuiteRunner.test.mjs packages/api/test/connectAuth.test.ts packages/api/test/attachmentUploadCleanup.test.ts packages/api/test/configReloadSubscription.test.ts packages/api/test/dockerCommandSafety.test.ts packages/api/test/listenAddress.test.ts packages/api/test/oauthState.test.ts packages/api/test/requestRateLimits.test.ts packages/api/test/statusRoutes.test.ts packages/api/test/agentRuntimeRoutes.test.ts packages/api/test/instanceAuthorization.test.ts packages/api/test/routeAuthorization.test.ts", + "test:unit": "NODE_ENV=test npx tsx --experimental-test-module-mocks --test test/minimal.test.ts test/modelName.test.ts test/agentContainerResources.test.ts test/agentDockerfileSupplyChain.test.ts test/daemonEventIntake.test.ts test/databaseMigrationGate.test.ts test/generateContext.test.ts test/githubEventIntakeMode.test.ts test/intakeModePrerequisites.test.ts test/orchestratorMigrationPhase.test.mjs test/validateRoutingUrl.test.ts test/routingWebSocketProtocol.test.ts test/routingWebSocketIntakeService.test.ts test/routingStatusPublisher.test.ts test/releaseValidation.test.mjs test/sessionSecret.test.ts test/testSuiteRunner.test.mjs packages/api/test/connectAuth.test.ts packages/api/test/attachmentUploadCleanup.test.ts packages/api/test/configReloadSubscription.test.ts packages/api/test/desktopApiBoundary.test.ts packages/api/test/dockerCommandSafety.test.ts packages/api/test/listenAddress.test.ts packages/api/test/oauthState.test.ts packages/api/test/requestRateLimits.test.ts packages/api/test/statusRoutes.test.ts packages/api/test/agentRuntimeRoutes.test.ts packages/api/test/instanceAuthorization.test.ts packages/api/test/routeAuthorization.test.ts", "test:e2e": "npx tsx --test test/e2e.test.ts", "test:docker": "docker-compose run --rm -e REDIS_HOST=redis -e NODE_ENV=test worker npx tsx --test test/*.test.ts", "test:docker:single": "docker-compose run --rm -e REDIS_HOST=redis -e NODE_ENV=test worker npx tsx --test", diff --git a/packages/api/desktopApiBoundary.ts b/packages/api/desktopApiBoundary.ts new file mode 100644 index 000000000..71a0cf69f --- /dev/null +++ b/packages/api/desktopApiBoundary.ts @@ -0,0 +1,39 @@ +import type { Express, RequestHandler } from 'express'; +import { ensureAuthenticated } from './auth.js'; +import { resolveAuthorization } from './authorization.js'; +import { + createDiscoveryRequestRateLimiter, + createPairingPollRateLimiter, + createPairingStartRateLimiter, +} from './requestRateLimits.js'; + +export interface DesktopApiBoundaryRoutes { + discovery: RequestHandler; + startPairing: RequestHandler; + pollPairing: RequestHandler; + activatePairing: RequestHandler; + cancelPairing: RequestHandler; + openPairingApproval: RequestHandler; + revokeCurrentToken: RequestHandler; +} + +/** + * Register the complete public desktop bootstrap boundary and then close it + * with the generic API authentication/authorization guard. Operational routes + * must be registered only after this function returns. + */ +export function registerDesktopApiBoundary( + app: Express, + routes: DesktopApiBoundaryRoutes, +): void { + app.get('/api/desktop/discovery', createDiscoveryRequestRateLimiter(), routes.discovery); + app.post('/api/desktop/pairings', createPairingStartRateLimiter(), routes.startPairing); + app.post('/api/desktop/pairings/:pairingId/poll', createPairingPollRateLimiter(), routes.pollPairing); + app.post('/api/desktop/pairings/:pairingId/activate', createPairingPollRateLimiter(), routes.activatePairing); + app.post('/api/desktop/pairings/:pairingId/cancel', createPairingPollRateLimiter(), routes.cancelPairing); + app.get('/api/desktop/pairings/:pairingId/browser', createPairingStartRateLimiter(), routes.openPairingApproval); + // Token possession authorizes only this exact self-revocation route. It must + // precede generic auth so inactive tokens receive a stable terminal contract. + app.delete('/api/desktop/tokens/current', routes.revokeCurrentToken); + app.use('/api', ensureAuthenticated, resolveAuthorization); +} diff --git a/packages/api/server.ts b/packages/api/server.ts index 30903be6b..2c28f1444 100644 --- a/packages/api/server.ts +++ b/packages/api/server.ts @@ -6,7 +6,7 @@ import { createClient, RedisClientType } from 'redis'; import { Queue } from 'bullmq'; import 'dotenv/config'; import { Redis, RedisOptions } from 'ioredis'; -import { authenticateSocketRequest, setupAuth, ensureAuthenticated } from './auth.js'; +import { authenticateSocketRequest, setupAuth } from './auth.js'; import { configureDemoMode, createDemoRedisClient, demoModeReadOnlyMiddleware } from './demoMode.js'; import { resolveGithubAuthMode, resolveGithubEventIntakeMode, validateIntakeModePrerequisites } from '@propr/shared'; import { initSocketService, closeSocketService } from './services/socketService.js'; @@ -61,14 +61,12 @@ import { stopTaskExecution } from './routes/dockerRoutes.js'; import { initializePushSubscriptionMaintenance } from './services/pushSubscriptionMaintenance.js'; import { NotificationProjectionService } from './services/notificationProjectionService.js'; import { WebPushDispatcher } from './services/webPushDispatcher.js'; -import { assertInstanceAdministratorConfigured, resolveAuthorization } from './authorization.js'; +import { assertInstanceAdministratorConfigured } from './authorization.js'; import { resolveApiListenHost } from './listenAddress.js'; import { configureApiProxyTrust, createApiRequestRateLimiter, createDiscoveryRequestRateLimiter, - createPairingPollRateLimiter, - createPairingStartRateLimiter, createWebhookRequestRateLimiter, } from './requestRateLimits.js'; import { desktopAuthService } from './desktopAuthService.js'; @@ -82,6 +80,7 @@ import { type RouteEntry } from './routeRegistry.js'; import { createTaskDeleteRouteEntries } from './taskDeleteRouteRegistry.js'; +import { registerDesktopApiBoundary } from './desktopApiBoundary.js'; type ShutdownTask = { name: string; close: () => Promise }; @@ -256,16 +255,15 @@ function setupRoutes(): void { // They return only compatibility/capability metadata or pairing state gated by // a high-entropy secret; all operational routes below remain authenticated. app.get('/api/compatibility', createDiscoveryRequestRateLimiter(), statusRoutes.getCompatibility); - app.get('/api/desktop/discovery', createDiscoveryRequestRateLimiter(), statusRoutes.getDesktopDiscovery); - app.post('/api/desktop/pairings', createPairingStartRateLimiter(), desktopAuthRoutes.startPairing); - app.post('/api/desktop/pairings/:pairingId/poll', createPairingPollRateLimiter(), desktopAuthRoutes.pollPairing); - app.post('/api/desktop/pairings/:pairingId/activate', createPairingPollRateLimiter(), desktopAuthRoutes.activatePairing); - app.post('/api/desktop/pairings/:pairingId/cancel', createPairingPollRateLimiter(), desktopAuthRoutes.cancelPairing); - app.get('/api/desktop/pairings/:pairingId/browser', createPairingStartRateLimiter(), desktopAuthRoutes.openPairingApproval); - // Token possession authorizes only this exact self-revocation route. It must - // precede generic auth so inactive tokens receive a stable terminal contract. - app.delete('/api/desktop/tokens/current', desktopAuthRoutes.revokeCurrentToken); - app.use('/api', ensureAuthenticated, resolveAuthorization); + registerDesktopApiBoundary(app, { + discovery: statusRoutes.getDesktopDiscovery, + startPairing: desktopAuthRoutes.startPairing, + pollPairing: desktopAuthRoutes.pollPairing, + activatePairing: desktopAuthRoutes.activatePairing, + cancelPairing: desktopAuthRoutes.cancelPairing, + openPairingApproval: desktopAuthRoutes.openPairingApproval, + revokeCurrentToken: desktopAuthRoutes.revokeCurrentToken, + }); app.get('/api/desktop/pairings/:pairingId/approval', desktopAuthRoutes.browserSessionGuard, desktopAuthRoutes.getPairingApproval); app.post('/api/desktop/pairings/:pairingId/approve', desktopAuthRoutes.browserSessionGuard, desktopAuthRoutes.approvalOriginGuard, desktopAuthRoutes.approvePairing); app.get('/api/desktop/tokens', desktopAuthRoutes.listTokens); diff --git a/packages/api/test/desktopApiBoundary.test.ts b/packages/api/test/desktopApiBoundary.test.ts new file mode 100644 index 000000000..fe1dd2811 --- /dev/null +++ b/packages/api/test/desktopApiBoundary.test.ts @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict'; +import type { AddressInfo } from 'node:net'; +import { after, describe, test } from 'node:test'; +import express, { type RequestHandler } from 'express'; +import { closeConnection } from '@propr/core'; +import { registerDesktopApiBoundary, type DesktopApiBoundaryRoutes } from '../desktopApiBoundary.js'; + +after(async () => closeConnection()); + +const reached = (name: string): RequestHandler => (_req, res) => { + res.status(204).set('X-ProPR-Route', name).end(); +}; + +const publicRoutes: DesktopApiBoundaryRoutes = { + discovery: reached('discovery'), + startPairing: reached('start'), + pollPairing: reached('poll'), + activatePairing: reached('activate'), + cancelPairing: reached('cancel'), + openPairingApproval: reached('browser'), + revokeCurrentToken: reached('revoke'), +}; + +const fetchFromApp = async ( + app: express.Express, + path: string, + init?: RequestInit, +): Promise => { + const server = app.listen(0, '127.0.0.1'); + try { + await new Promise(resolve => server.once('listening', resolve)); + const { port } = server.address() as AddressInfo; + return await fetch(`http://127.0.0.1:${port}${path}`, init); + } finally { + await new Promise((resolve, reject) => { + server.close(error => error ? reject(error) : resolve()); + }); + } +}; + +describe('assembled desktop API authentication boundary', () => { + test('keeps discovery and bounded pairing bootstrap ahead of the operational API guard', async () => { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.isAuthenticated = () => false; + next(); + }); + registerDesktopApiBoundary(app, publicRoutes); + app.get('/api/status', (_req, res) => res.json({ operational: true })); + + for (const [method, path, expected] of [ + ['GET', '/api/desktop/discovery', 'discovery'], + ['POST', '/api/desktop/pairings', 'start'], + ['POST', '/api/desktop/pairings/dpr_AAAAAAAAAAAAAAAAAAAAAA/poll', 'poll'], + ['POST', '/api/desktop/pairings/dpr_AAAAAAAAAAAAAAAAAAAAAA/activate', 'activate'], + ['POST', '/api/desktop/pairings/dpr_AAAAAAAAAAAAAAAAAAAAAA/cancel', 'cancel'], + ['GET', '/api/desktop/pairings/dpr_AAAAAAAAAAAAAAAAAAAAAA/browser', 'browser'], + ] as const) { + const response = await fetchFromApp(app, path, { method }); + assert.equal(response.status, 204, `${method} ${path}`); + assert.equal(response.headers.get('x-propr-route'), expected, `${method} ${path}`); + } + + const protectedResponse = await fetchFromApp(app, '/api/status'); + assert.equal(protectedResponse.status, 401); + assert.deepEqual(await protectedResponse.json(), { error: 'Unauthorized' }); + }); +}); diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index ce6c21baa..a78370f55 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -1,5 +1,7 @@ import { evaluateProprApiCompatibility, + parseProprDesktopDiscoveryJson, + PROPR_CONNECT_DISCOVERY_MAX_BYTES, type ProprApiCompatibilityResult, type ProprCompatibilityMetadata, } from '@propr/shared'; @@ -9,7 +11,7 @@ import { type NormalizeApiBaseUrlOptions, type ProprApiBaseUrl, } from './baseUrl.js'; -import { ProprClientError } from './errors.js'; +import { DESKTOP_DISCOVERY_AUTHENTICATION_REQUIRED, ProprClientError } from './errors.js'; import { buildSocketConnection, connectProprSocket, @@ -233,14 +235,94 @@ export class ProprClient { } async discoverDesktop(timeoutMs = 8000, signal?: AbortSignal): Promise { - const metadata = await this.request('/api/desktop/discovery', { + const response = await this.fetch(this.url('/api/desktop/discovery'), { cache: 'no-store', + credentials: 'omit', + headers: { Accept: 'application/json' }, + redirect: 'manual', signal, }, { timeoutMs }); + const discoveryContentType = response.headers.get('content-type') + ?.split(';', 1)[0]?.trim().toLowerCase(); + if (!response.ok || response.redirected || discoveryContentType !== 'application/json') { + try { void response.body?.cancel().catch(() => undefined); } catch { /* best-effort response disposal */ } + const authenticationGated = response.status === 401 + && !response.redirected + && discoveryContentType === 'application/json'; + throw new ProprClientError('The ProPR instance returned invalid desktop discovery metadata.', { + kind: 'invalid_response', + status: response.status, + ...(authenticationGated ? { code: DESKTOP_DISCOVERY_AUTHENTICATION_REQUIRED } : {}), + }); + } + const declaredLength = response.headers.get('content-length'); + if (declaredLength !== null && (!/^(?:0|[1-9]\d*)$/.test(declaredLength) + || Number(declaredLength) > PROPR_CONNECT_DISCOVERY_MAX_BYTES)) { + try { void response.body?.cancel().catch(() => undefined); } catch { /* best-effort response disposal */ } + throw new ProprClientError('The ProPR instance returned oversized desktop discovery metadata.', { + kind: 'invalid_response', status: response.status, + }); + } + const reader = response.body?.getReader(); + const chunks: Uint8Array[] = []; + let received = 0; + let rejectDeadline!: (reason: unknown) => void; + let bodyTimedOut = false; + const deadline = new Promise((_resolve, reject) => { rejectDeadline = reject; }); + const bodyTimer = setTimeout(() => { + bodyTimedOut = true; + rejectDeadline(new Error('desktop discovery body timed out')); + }, Math.max(1, timeoutMs)); + const onAbort = (): void => rejectDeadline(signal?.reason ?? new Error('desktop discovery was cancelled')); + if (signal?.aborted) onAbort(); + else signal?.addEventListener('abort', onAbort, { once: true }); + try { + if (reader) { + while (true) { + const part = await Promise.race([reader.read(), deadline]); + if (part.done) break; + received += part.value.byteLength; + if (received > PROPR_CONNECT_DISCOVERY_MAX_BYTES) throw new Error('oversized'); + chunks.push(part.value); + } + } + } catch (cause) { + try { void reader?.cancel().catch(() => undefined); } catch { /* best-effort body cancellation */ } + if (bodyTimedOut) throw new ProprClientError('Desktop discovery timed out.', { kind: 'timeout', cause }); + if (signal?.aborted) throw new ProprClientError('Desktop discovery was cancelled.', { kind: 'aborted', cause }); + throw new ProprClientError('The ProPR instance returned invalid desktop discovery metadata.', { + kind: 'invalid_response', status: response.status, cause, + }); + } finally { + clearTimeout(bodyTimer); + signal?.removeEventListener('abort', onAbort); + try { reader?.releaseLock(); } catch { /* hostile streams may retain a pending read */ } + } + const contentEncoding = response.headers.get('content-encoding')?.trim().toLowerCase(); + if (declaredLength !== null && (!contentEncoding || contentEncoding === 'identity') + && Number(declaredLength) !== received) { + throw new ProprClientError('The ProPR instance returned invalid desktop discovery metadata.', { + kind: 'invalid_response', status: response.status, + }); + } + const bytes = new Uint8Array(received); + let cursor = 0; + for (const chunk of chunks) { bytes.set(chunk, cursor); cursor += chunk.byteLength; } + let contents: string; + try { contents = new TextDecoder('utf-8', { fatal: true }).decode(bytes); } + catch (cause) { + throw new ProprClientError('The ProPR instance returned invalid desktop discovery metadata.', { + kind: 'invalid_response', status: response.status, cause, + }); + } + const metadata = parseProprDesktopDiscoveryJson(contents); + if (!metadata) { + throw new ProprClientError('The ProPR instance returned invalid desktop discovery metadata.', { + kind: 'invalid_response', status: response.status, + }); + } const compatibility = evaluateProprApiCompatibility( - metadata && typeof metadata === 'object' - ? metadata as Partial - : {}, + metadata, ); return parseDesktopDiscovery(metadata, compatibility); } diff --git a/packages/client/src/errors.ts b/packages/client/src/errors.ts index 6a5af7ae1..75ed8fa8b 100644 --- a/packages/client/src/errors.ts +++ b/packages/client/src/errors.ts @@ -8,6 +8,10 @@ export type ProprClientErrorKind = | 'invalid_response' | 'compatibility'; +/** The exact credential-free public discovery request was authentication-gated. */ +export const DESKTOP_DISCOVERY_AUTHENTICATION_REQUIRED = + 'DESKTOP_DISCOVERY_AUTHENTICATION_REQUIRED' as const; + export interface ProprClientErrorOptions { kind: ProprClientErrorKind; status?: number; diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 7ff0e4a39..84f5a37ab 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -15,6 +15,7 @@ export { type ProprRequestOptions, } from './client.js'; export { + DESKTOP_DISCOVERY_AUTHENTICATION_REQUIRED, isProprClientError, ProprClientError, type ProprClientErrorKind, diff --git a/packages/client/test/desktopPairing.test.ts b/packages/client/test/desktopPairing.test.ts index 3cd206b39..451d2efa7 100644 --- a/packages/client/test/desktopPairing.test.ts +++ b/packages/client/test/desktopPairing.test.ts @@ -1,7 +1,11 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { PROPR_API_COMPATIBILITY, PROPR_UI_COMPATIBILITY } from '@propr/shared'; -import { ProprClient, ProprClientError } from '../src/index.js'; +import { + DESKTOP_DISCOVERY_AUTHENTICATION_REQUIRED, + ProprClient, + ProprClientError, +} from '../src/index.js'; const json = (body: unknown, status = 200): Response => new Response(JSON.stringify(body), { status, @@ -9,10 +13,13 @@ const json = (body: unknown, status = 200): Response => new Response(JSON.string }); const discovery = { + schemaVersion: 1 as const, product: 'ProPR', version: '0.8.15', apiCompatibility: PROPR_API_COMPATIBILITY, uiCompatibility: PROPR_UI_COMPATIBILITY, + canonicalEndpoint: null, + publicInstanceIdentity: '123e4567-e89b-42d3-a456-426614174000', desktopAuthentication: { protocolVersion: 2 as const, browserPairing: true, @@ -71,6 +78,78 @@ class PairingClock { } describe('desktop instance protocol', () => { + it('strictly classifies only the credential-free public discovery 401', async () => { + let discoveryBodyRead = false; + const legacy = new ProprClient({ + baseUrl: 'https://propr.example.test', + authentication: { type: 'none' }, + fetch: async (_input, init) => { + assert.equal(init?.credentials, 'omit'); + assert.equal(init?.redirect, 'manual'); + const response = new Response('{"private":"proxy policy detail"}', { + status: 401, headers: { 'Content-Type': 'application/json' }, + }); + response.text = async () => { + discoveryBodyRead = true; + throw new Error('the 401 body must not be consumed'); + }; + response.json = async () => { + discoveryBodyRead = true; + throw new Error('the 401 body must not be consumed'); + }; + return response; + }, + }); + await assert.rejects(legacy.discoverDesktop(), (error: unknown) => + error instanceof ProprClientError + && error.kind === 'invalid_response' + && error.status === 401 + && error.code === DESKTOP_DISCOVERY_AUTHENTICATION_REQUIRED); + assert.equal(discoveryBodyRead, false); + + const operational = new ProprClient({ + baseUrl: 'https://propr.example.test', + authentication: { type: 'none' }, + fetch: async () => json({ code: 'AUTHENTICATION_REQUIRED' }, 401), + }); + await assert.rejects(operational.request('/api/tasks'), (error: unknown) => + error instanceof ProprClientError + && error.kind === 'http' + && error.status === 401 + && error.code === 'AUTHENTICATION_REQUIRED'); + + const htmlPolicy = new ProprClient({ + baseUrl: 'https://propr.example.test', + authentication: { type: 'none' }, + fetch: async () => new Response('

Policy login required

', { + status: 401, headers: { 'Content-Type': 'text/html' }, + }), + }); + await assert.rejects(htmlPolicy.discoverDesktop(), (error: unknown) => + error instanceof ProprClientError + && error.kind === 'invalid_response' + && error.status === 401 + && error.code === undefined); + }); + + it('uses the shared strict wire parser for malformed and oversized discovery', async () => { + const valid = JSON.stringify(discovery); + for (const body of [ + JSON.stringify((({ publicInstanceIdentity: _omitted, ...rest }) => rest)(discovery)), + JSON.stringify({ ...discovery, unexpected: true }), + valid.replace('"product":"ProPR"', '"product":"ProPR","product":"ProPR"'), + `${valid}${' '.repeat(8 * 1024)}`, + ]) { + const client = new ProprClient({ + baseUrl: 'https://propr.example.test', + authentication: { type: 'none' }, + fetch: async () => new Response(body, { headers: { 'Content-Type': 'application/json' } }), + }); + await assert.rejects(client.discoverDesktop(), (error: unknown) => + error instanceof ProprClientError && error.kind === 'invalid_response'); + } + }); + it('discovers capabilities, opens approval, and polls to a single opaque token', async () => { const requests: Array<{ url: string; init?: RequestInit }> = []; let polls = 0; diff --git a/packages/shared/src/connectDiscovery.ts b/packages/shared/src/connectDiscovery.ts index 797bf9627..9132eaab0 100644 --- a/packages/shared/src/connectDiscovery.ts +++ b/packages/shared/src/connectDiscovery.ts @@ -122,3 +122,88 @@ export function parseProprDesktopDiscovery(value: unknown): ProprDesktopDiscover }, }; } + +/** + * Parse discovery from its bounded wire representation. JSON.parse accepts + * duplicate object members, so discovery performs a structural pass before + * the schema parser. This keeps every client on the same fail-closed contract. + */ +export function parseProprDesktopDiscoveryJson(contents: string): ProprDesktopDiscovery | null { + if (typeof contents !== 'string' + || new TextEncoder().encode(contents).byteLength > PROPR_CONNECT_DISCOVERY_MAX_BYTES) return null; + + let offset = 0; + const whitespace = (): void => { + while (offset < contents.length && /[\x20\t\r\n]/.test(contents[offset])) offset += 1; + }; + const stringToken = (): string | null => { + if (contents[offset] !== '"') return null; + const start = offset; + offset += 1; + while (offset < contents.length) { + const character = contents[offset++]; + if (character === '"') { + try { return JSON.parse(contents.slice(start, offset)) as string; } catch { return null; } + } + if (character === '\\') { + const escape = contents[offset++]; + if (escape === 'u') { + if (!/^[0-9a-fA-F]{4}$/.test(contents.slice(offset, offset + 4))) return null; + offset += 4; + } else if (!escape || !'"\\/bfnrt'.includes(escape)) return null; + } else if (character.charCodeAt(0) < 0x20) return null; + } + return null; + }; + const value = (): boolean => { + whitespace(); + if (contents[offset] === '{') { + offset += 1; + whitespace(); + const keys = new Set(); + if (contents[offset] === '}') { offset += 1; return true; } + while (offset < contents.length) { + const key = stringToken(); + if (key === null || keys.has(key)) return false; + keys.add(key); + whitespace(); + if (contents[offset++] !== ':') return false; + if (!value()) return false; + whitespace(); + const separator = contents[offset++]; + if (separator === '}') return true; + if (separator !== ',') return false; + whitespace(); + } + return false; + } + if (contents[offset] === '[') { + offset += 1; + whitespace(); + if (contents[offset] === ']') { offset += 1; return true; } + while (offset < contents.length) { + if (!value()) return false; + whitespace(); + const separator = contents[offset++]; + if (separator === ']') return true; + if (separator !== ',') return false; + } + return false; + } + if (contents[offset] === '"') return stringToken() !== null; + const primitive = /^(?:true|false|null|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)/ + .exec(contents.slice(offset))?.[0]; + if (!primitive) return false; + offset += primitive.length; + return true; + }; + + if (!value()) return null; + whitespace(); + if (offset !== contents.length) return null; + try { + return parseProprDesktopDiscovery(JSON.parse(contents) as unknown); + } catch { + return null; + } +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index f2f67d75d..ffff1dc46 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -141,6 +141,7 @@ export { PUBLIC_INSTANCE_IDENTITY_FILENAME, isPublicInstanceIdentity, parseProprDesktopDiscovery, + parseProprDesktopDiscoveryJson, parsePublicInstanceIdentityDocument, type PublicInstanceIdentityDocument, type ProprDesktopDiscovery, From 8cd66a3791366b86d061baba55b176e52c51072e Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:30:14 +0000 Subject: [PATCH 3/9] =?UTF-8?q?feat(ai):=20The=20repository-wide=20runner?= =?UTF-8?q?=20has=20passed=20the=20assembled=20API=20boundary=20and=20all?= =?UTF-8?q?=20transport-sensitive=20desktop/API=20suites;=20it=20is=20now?= =?UTF-8?q?=20past=20110=20of=20374=20files=20with=20no=20failures.=20I?= =?UTF-8?q?=E2=80=99m=20letting=20the=20sequential=20runner=20finish=20so?= =?UTF-8?q?=20the=20final=20result=20reflects=20the=20complete=20merged=20?= =?UTF-8?q?tree.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repository-wide runner has passed the assembled API boundary and all transport-sensitive desktop/API suites; it is now past 110 of 374 files with no failures. I’m letting the sequential runner finish so the final result reflects the complete merged tree. PR: #2089 Comment by: @integry (ID: 5531489680) Model: gpt-5.6-sol --- .env.example | 8 +- .github/workflows/desktop-release-guard.yml | 56 +- Dockerfile.agent | 4 +- apps/desktop/README.md | 5 +- .../scripts/cleanup-installed-windows-app.ps1 | 1809 +++++++++++++ .../scripts/packaged-smoke-support.test.mjs | 19 + .../run-installed-windows-app-harness.ps1 | 1276 +++++++++ ...lled-windows-app-workflow-cleanup-body.ps1 | 553 ++++ ...installed-windows-app-workflow-cleanup.ps1 | 90 + .../desktop/scripts/run-native-durability.mjs | 5 +- apps/desktop/scripts/smoke-packaged.mjs | 9 +- ...stalled-windows-app-supervisor-fixture.ps1 | 1016 +++++++ .../test-installed-windows-app-supervisor.ps1 | 2393 +++++++++++++++++ .../scripts/test-installed-windows-app.ps1 | 1834 ++++++++++++- apps/desktop/src/connect-discovery.test.ts | 152 ++ apps/desktop/src/connect-discovery.ts | 201 +- ...credential-service.pairing-browser.test.ts | 17 +- apps/desktop/src/credential-service.test.ts | 550 +++- apps/desktop/src/credential-service.ts | 239 +- apps/desktop/src/main.ts | 16 +- .../src/pairing-response-lifecycle.test.ts | 22 +- .../src/pending-revocation-crash-fixture.ts | 19 +- .../src/profile-store-crash-fixture.ts | 3 +- apps/desktop/src/profile-store.test.ts | 32 +- apps/desktop/src/profile-store.ts | 82 +- apps/desktop/src/release-workflow.test.ts | 837 +++++- docs/docs/architecture/agent-runtime.md | 5 +- docs/docs/concepts/glossary.md | 12 + docs/docs/features/agents-and-models.md | 2 + docs/docs/features/propr-cli.md | 11 + docs/docs/features/synthetic-pools.md | 114 + docs/docs/features/web-ui.md | 2 + .../operations/configuration-reference.md | 5 +- docs/docs/operations/desktop-pairing.md | 23 +- docs/docs/operations/hosted-ui-tunnel.md | 2 +- docs/sidebars.ts | 1 + package-lock.json | 287 +- package.json | 2 +- packages/api/README.md | 1 + packages/api/permissionGuards.ts | 13 + packages/api/routeRegistry.ts | 8 +- packages/api/routes/agentRoutes.ts | 112 +- packages/api/routes/configRepoValidation.ts | 21 + packages/api/routes/configRoutes.ts | 50 +- .../api/routes/configRoutesAgentDefaults.ts | 26 + packages/api/routes/configRoutesAgents.ts | 102 +- .../routes/configRoutesAgentsPreparation.ts | 10 +- .../api/routes/configRoutesAgentsTypes.ts | 1 + .../api/routes/configRoutesSyntheticAgents.ts | 153 ++ packages/api/routes/instanceCatalogRoutes.ts | 37 +- packages/api/routes/notificationRoutes.ts | 15 + packages/api/routes/statusRoutes.ts | 49 +- packages/api/server.ts | 2 +- .../services/notificationProjectionService.ts | 305 ++- packages/api/test/configRepoRoutes.test.ts | 152 ++ .../api/test/configRepoValidation.test.ts | 48 + .../api/test/instanceAuthorization.test.ts | 3 + .../test/notificationManagementRoutes.test.ts | 1 + .../test/notificationProjectionRace.test.ts | 112 + .../notificationProjectionService.test.ts | 164 +- .../test/notificationProjectionTestHarness.ts | 110 + packages/api/test/notificationRoutes.test.ts | 23 + packages/api/test/routeAuthorization.test.ts | 24 +- packages/api/test/statusRoutes.test.ts | 44 + .../api/test/syntheticAgentContracts.test.ts | 163 ++ packages/api/test/syntheticAgents.test.ts | 426 +++ packages/api/test/webPushDispatcher.test.ts | 22 +- packages/cli/src/api/index.ts | 12 + packages/cli/src/api/repos.test.ts | 43 + packages/cli/src/api/repos.ts | 17 + packages/cli/src/api/syntheticPools.test.ts | 77 + packages/cli/src/api/syntheticPools.ts | 58 + packages/cli/src/commands/agentCommands.ts | 3 + .../src/commands/agentPoolCommands.test.ts | 103 + .../cli/src/commands/agentPoolCommands.ts | 113 + packages/cli/src/commands/connectCommand.ts | 11 +- .../cli/src/commands/repoCommands.test.ts | 91 + packages/cli/src/commands/repoCommands.ts | 56 +- .../src/commands/taskInspectCommands.test.ts | 7 +- packages/cli/src/index.ts | 11 +- packages/client/src/client.ts | 147 +- packages/client/src/desktopPairing.ts | 34 +- packages/client/test/desktopPairing.test.ts | 103 +- packages/core/src/agents/AgentRegistry.ts | 31 +- packages/core/src/agents/SyntheticAgent.ts | 71 + .../core/src/agents/SyntheticAgentRegistry.ts | 58 + .../core/src/agents/createAgentFromConfig.ts | 23 + .../core/src/agents/impl/AntigravityAgent.ts | 16 +- packages/core/src/agents/impl/ClaudeAgent.ts | 8 +- packages/core/src/agents/impl/CodexAgent.ts | 10 +- .../core/src/agents/impl/OpenCodeAgent.ts | 9 +- packages/core/src/agents/impl/VibeAgent.ts | 4 +- .../agents/impl/utils/claudeOutputHelpers.ts | 1 + .../impl/utils/codexDockerArgsBuilder.ts | 67 + packages/core/src/agents/syntheticRouting.ts | 2 + packages/core/src/agents/types.ts | 3 + packages/core/src/agents/version/types.ts | 2 +- packages/core/src/claude/claudeService.ts | 15 +- packages/core/src/codex/codexHelpers.ts | 11 +- packages/core/src/config/configManager.ts | 7 + .../config/configManagerSyntheticAgents.ts | 35 + packages/core/src/daemon/configLoader.ts | 32 +- packages/core/src/db/migrationGate.ts | 44 +- ...010000_add_notification_preference_apis.js | 40 +- ...0_add_notification_system_failure_state.js | 44 + ...000_add_notification_pull_request_state.js | 37 + ...000000_create_synthetic_routing_cursors.js | 18 + packages/core/src/index.ts | 23 +- .../core/src/services/notificationService.ts | 495 +++- .../src/services/planning/planningTypes.ts | 2 + .../src/services/planning/planningUtils.ts | 4 +- .../relevance/contextAnalysisConfig.ts | 2 +- .../services/relevance/keywordExtractor.ts | 51 +- .../src/services/relevance/semanticScorer.ts | 63 +- .../services/relevance/summaryMinerBatch.ts | 378 ++- .../relevance/summaryMinerBatchHelpers.ts | 75 + .../relevance/summaryMinerBatchPersistence.ts | 14 +- .../relevance/summaryMinerDirectories.ts | 64 +- .../relevance/summaryMinerDirectoryBatch.ts | 119 +- .../services/relevance/summaryMinerHelpers.ts | 55 +- .../core/src/services/relevanceService.ts | 35 +- .../src/services/syntheticRoutingService.ts | 449 ++++ .../src/services/syntheticRoutingTypes.ts | 102 + .../syntheticUsageSnapshotProvider.ts | 57 + .../src/services/taskPlanning/llmCalling.ts | 5 +- .../src/services/taskPlanning/refinement.ts | 19 +- .../core/src/services/taskPlanning/types.ts | 3 + .../core/src/services/taskPlanningService.ts | 43 +- packages/core/src/webhook/checkRunHandler.ts | 81 +- .../core/src/webhook/ciFailureFollowup.ts | 355 +++ .../core/src/webhook/commentEventHandler.ts | 51 +- .../core/src/webhook/planIssueTracking.ts | 15 + .../core/test/notificationService.test.ts | 294 ++ .../core/test/syntheticRoutingService.test.ts | 346 +++ packages/shared/package.json | 3 + packages/shared/src/connectDiscovery.ts | 7 +- packages/shared/src/index.ts | 20 + packages/shared/src/instanceCatalog.ts | 4 + packages/shared/src/modelDefinitions.ts | 2 +- packages/shared/src/syntheticAgents.ts | 224 ++ propr-ui/src/api/agentChatApi.ts | 8 + propr-ui/src/api/configApi.ts | 18 + propr-ui/src/api/notificationApi.test.ts | 20 +- propr-ui/src/api/notificationApi.ts | 6 + .../src/api/proprApi.instanceCatalog.test.ts | 34 + propr-ui/src/api/proprApi.ts | 4 +- propr-ui/src/api/proprTypes.ts | 2 + propr-ui/src/components/AddRepositoryForm.tsx | 26 +- .../src/components/AddRepositoryModal.tsx | 20 + .../src/components/AgentChat/ChatPanel.tsx | 59 +- propr-ui/src/components/AgentTankSidebar.tsx | 37 +- .../src/components/GlobalHeaderComponents.tsx | 10 +- propr-ui/src/components/Layout.tsx | 4 +- .../MobileBottomNavigation.test.tsx | 113 +- .../src/components/MobileBottomNavigation.tsx | 8 + .../ModelContextSelector.test.tsx | 36 + .../Repositories/ModelContextSelector.tsx | 37 +- .../Repositories/RepoActionContainer.tsx | 11 + .../components/Repositories/RepoChatPanel.tsx | 6 +- .../Repositories/RepoImprovementsPanel.tsx | 2 + .../RepoImprovementsPanel.types.ts | 2 + .../src/components/RepositoryListContent.tsx | 3 + .../src/components/RepositoryListItem.tsx | 37 + propr-ui/src/components/SystemStatus.tsx | 7 +- .../components/TaskDetails/ContextStrip.tsx | 14 +- .../components/TaskDetails/LeftPaneBody.tsx | 18 + .../TaskDetails/TaskStatusTable.tsx | 14 + propr-ui/src/components/TaskDetails/index.tsx | 1 + propr-ui/src/components/TaskDetails/types.ts | 11 + .../components/TaskDetails/useHistoryData.ts | 9 +- propr-ui/src/hooks/useDesktopLayout.ts | 26 + .../hooks/useRepositoryManagement.test.tsx | 126 + propr-ui/src/hooks/useRepositoryManagement.ts | 64 +- propr-ui/src/pages/AiAgentsPage.test.tsx | 223 +- propr-ui/src/pages/AiAgentsPage.tsx | 215 +- propr-ui/src/pages/InboxPage.test.tsx | 51 + propr-ui/src/pages/InboxPage.tsx | 51 +- propr-ui/src/pages/LlmLogsPage.tsx | 4 +- propr-ui/src/pages/LlmLogsPageComponents.tsx | 45 +- propr-ui/src/pages/RepositoriesPage.tsx | 11 +- .../AIModelSelectionSection.test.tsx | 35 + .../SettingsPage/AIModelSelectionSection.tsx | 28 +- .../SettingsPage/ReviewContextSettings.tsx | 5 +- propr-ui/src/pages/SettingsPage/index.tsx | 2 + .../SettingsPage/modelSelectionHelpers.ts | 18 +- .../pages/SettingsPage/useSettingsState.ts | 19 +- propr-ui/src/pages/SyntheticPoolsSection.tsx | 383 +++ propr-ui/src/pages/useInboxNotifications.ts | 49 +- propr-ui/src/utils/agentStatus.ts | 1 + scripts/build-images.sh | 2 +- scripts/deploy-pr.sh | 85 +- src/daemon.ts | 2 +- src/jobs/prCommentReviewJob.ts | 80 +- src/jobs/prReviewRunner.ts | 21 +- src/jobs/reviewContextScout.ts | 61 +- src/worker.ts | 4 +- test/checkRunHandler.test.ts | 51 +- test/ciFailureFollowup.test.ts | 134 + test/codexHelpers.test.ts | 23 + test/commentEventHandler.switch-use.test.ts | 61 +- test/contextAnalysisRuntime.test.ts | 139 +- test/databaseMigrationGate.test.ts | 47 + test/deployPrPreview.test.mjs | 112 + test/monitoredRepositories.test.ts | 29 + test/notificationPreferenceMigration.test.ts | 34 + test/notificationPublicEntrypoint.test.ts | 5 + test/reviewContextScoutRuntime.test.ts | 66 +- test/summaryMinerBatchFallback.test.ts | 436 ++- 208 files changed, 20575 insertions(+), 1594 deletions(-) create mode 100644 apps/desktop/scripts/cleanup-installed-windows-app.ps1 create mode 100644 apps/desktop/scripts/run-installed-windows-app-harness.ps1 create mode 100644 apps/desktop/scripts/run-installed-windows-app-workflow-cleanup-body.ps1 create mode 100644 apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 create mode 100644 apps/desktop/scripts/test-installed-windows-app-supervisor-fixture.ps1 create mode 100644 apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 create mode 100644 docs/docs/features/synthetic-pools.md create mode 100644 packages/api/routes/configRoutesAgentDefaults.ts create mode 100644 packages/api/routes/configRoutesSyntheticAgents.ts create mode 100644 packages/api/test/configRepoRoutes.test.ts create mode 100644 packages/api/test/configRepoValidation.test.ts create mode 100644 packages/api/test/notificationProjectionRace.test.ts create mode 100644 packages/api/test/notificationProjectionTestHarness.ts create mode 100644 packages/api/test/syntheticAgentContracts.test.ts create mode 100644 packages/api/test/syntheticAgents.test.ts create mode 100644 packages/cli/src/api/repos.test.ts create mode 100644 packages/cli/src/api/syntheticPools.test.ts create mode 100644 packages/cli/src/api/syntheticPools.ts create mode 100644 packages/cli/src/commands/agentPoolCommands.test.ts create mode 100644 packages/cli/src/commands/agentPoolCommands.ts create mode 100644 packages/cli/src/commands/repoCommands.test.ts create mode 100644 packages/core/src/agents/SyntheticAgent.ts create mode 100644 packages/core/src/agents/SyntheticAgentRegistry.ts create mode 100644 packages/core/src/agents/createAgentFromConfig.ts create mode 100644 packages/core/src/agents/syntheticRouting.ts create mode 100644 packages/core/src/config/configManagerSyntheticAgents.ts create mode 100644 packages/core/src/db/migrations/20260829000000_add_notification_system_failure_state.js create mode 100644 packages/core/src/db/migrations/20260829010000_add_notification_pull_request_state.js create mode 100644 packages/core/src/db/migrations/20260830000000_create_synthetic_routing_cursors.js create mode 100644 packages/core/src/services/relevance/summaryMinerBatchHelpers.ts create mode 100644 packages/core/src/services/syntheticRoutingService.ts create mode 100644 packages/core/src/services/syntheticRoutingTypes.ts create mode 100644 packages/core/src/services/syntheticUsageSnapshotProvider.ts create mode 100644 packages/core/src/webhook/ciFailureFollowup.ts create mode 100644 packages/core/test/syntheticRoutingService.test.ts create mode 100644 packages/shared/src/syntheticAgents.ts create mode 100644 propr-ui/src/api/proprApi.instanceCatalog.test.ts create mode 100644 propr-ui/src/components/Repositories/ModelContextSelector.test.tsx create mode 100644 propr-ui/src/hooks/useDesktopLayout.ts create mode 100644 propr-ui/src/pages/SyntheticPoolsSection.tsx create mode 100644 test/ciFailureFollowup.test.ts create mode 100644 test/deployPrPreview.test.mjs diff --git a/.env.example b/.env.example index 17ec559d0..45f6f7d26 100644 --- a/.env.example +++ b/.env.example @@ -255,7 +255,13 @@ CLAUDE_CONFIG_PATH= CLAUDE_MAX_TURNS=10 CLAUDE_TIMEOUT_MS=86400000 CODEX_TIMEOUT_MS=86400000 -CONTEXT_ANALYSIS_TIMEOUT_MS=1800000 +# Codex response-stream policy. WebSockets avoid infrastructure HTTP response +# deadlines during long, quiet model turns. Use "sse" when WebSockets are not +# available, or "inherit" to use the mounted Codex provider configuration. +CODEX_STREAM_TRANSPORT=websocket +CODEX_STREAM_IDLE_TIMEOUT_MS=1800000 +CODEX_STREAM_MAX_RETRIES=5 +CONTEXT_ANALYSIS_TIMEOUT_MS=3600000 # Antigravity Configuration ANTIGRAVITY_TIMEOUT_MS=86400000 diff --git a/.github/workflows/desktop-release-guard.yml b/.github/workflows/desktop-release-guard.yml index bad35d543..500ba1fd9 100644 --- a/.github/workflows/desktop-release-guard.yml +++ b/.github/workflows/desktop-release-guard.yml @@ -138,6 +138,13 @@ jobs: EXPECTED_ARCH: ${{ matrix.arch }} run: node -e 'if(process.platform!==process.env.EXPECTED_PLATFORM||process.arch!==process.env.EXPECTED_ARCH) throw new Error(`Expected ${process.env.EXPECTED_PLATFORM}-${process.env.EXPECTED_ARCH}, got ${process.platform}-${process.arch}`)' + - name: Run focused Windows supervisor behavior tests + if: matrix.platform == 'win32' + shell: pwsh + run: | + & apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 ` + -Architecture '${{ matrix.arch }}' + - name: Audit committed dependency resolution shell: bash run: | @@ -230,11 +237,28 @@ jobs: run: | $installers = @(Get-ChildItem apps/desktop/out/make -Recurse -File -Filter '*Machine-Setup.msi') if ($installers.Count -ne 1) { throw 'Machine-wide Windows installer is missing or ambiguous' } - & apps/desktop/scripts/test-installed-windows-app.ps1 ` + $runId = [Guid]::NewGuid().ToString('N') + $ownershipManifest = Join-Path ([IO.Path]::GetTempPath()) ` + "propr-installed-app-ownership-$runId.json" + "PROPR_WINDOWS_INSTALLED_APP_RUN_ID=$runId" | Out-File -FilePath $env:GITHUB_ENV -Append + "PROPR_WINDOWS_INSTALLED_APP_MANIFEST=$ownershipManifest" | Out-File -FilePath $env:GITHUB_ENV -Append + "PROPR_WINDOWS_INSTALLED_APP_INSTALLER=$($installers[0].FullName)" | Out-File -FilePath $env:GITHUB_ENV -Append + & apps/desktop/scripts/run-installed-windows-app-harness.ps1 ` -Installer $installers[0].FullName ` - -Architecture '${{ matrix.arch }}' + -Architecture '${{ matrix.arch }}' ` + -OwnershipManifest $ownershipManifest ` + -ExpectedRunId $runId "PROPR_DESKTOP_WINDOWS_INSTALLED_APP=1" | Out-File -FilePath $env:GITHUB_ENV -Append + - name: Always clean Windows installed-app ownership + if: always() && matrix.platform == 'win32' && env.PROPR_WINDOWS_INSTALLED_APP_RUN_ID != '' + shell: pwsh + run: | + & apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 ` + -OwnershipManifest $env:PROPR_WINDOWS_INSTALLED_APP_MANIFEST ` + -Installer $env:PROPR_WINDOWS_INSTALLED_APP_INSTALLER ` + -ExpectedRunId $env:PROPR_WINDOWS_INSTALLED_APP_RUN_ID + - name: Launch packaged Linux application if: matrix.platform == 'linux' shell: bash @@ -473,6 +497,13 @@ jobs: EXPECTED_ARCH: ${{ matrix.arch }} run: node -e 'if(process.platform!==process.env.EXPECTED_PLATFORM||process.arch!==process.env.EXPECTED_ARCH) throw new Error(`Expected ${process.env.EXPECTED_PLATFORM}-${process.env.EXPECTED_ARCH}, got ${process.platform}-${process.arch}`)' + - name: Run focused Windows supervisor behavior tests + if: matrix.platform == 'win32' + shell: pwsh + run: | + & apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 ` + -Architecture '${{ matrix.arch }}' + - name: Audit committed dependency resolution shell: bash run: | @@ -675,11 +706,28 @@ jobs: run: | $installers = @(Get-ChildItem apps/desktop/out/make -Recurse -File -Filter '*Machine-Setup.msi') if ($installers.Count -ne 1) { throw 'Signed machine-wide Windows installer is missing or ambiguous' } - & apps/desktop/scripts/test-installed-windows-app.ps1 ` + $runId = [Guid]::NewGuid().ToString('N') + $ownershipManifest = Join-Path ([IO.Path]::GetTempPath()) ` + "propr-installed-app-ownership-$runId.json" + "PROPR_WINDOWS_INSTALLED_APP_RUN_ID=$runId" | Out-File -FilePath $env:GITHUB_ENV -Append + "PROPR_WINDOWS_INSTALLED_APP_MANIFEST=$ownershipManifest" | Out-File -FilePath $env:GITHUB_ENV -Append + "PROPR_WINDOWS_INSTALLED_APP_INSTALLER=$($installers[0].FullName)" | Out-File -FilePath $env:GITHUB_ENV -Append + & apps/desktop/scripts/run-installed-windows-app-harness.ps1 ` -Installer $installers[0].FullName ` - -Architecture '${{ matrix.arch }}' + -Architecture '${{ matrix.arch }}' ` + -OwnershipManifest $ownershipManifest ` + -ExpectedRunId $runId "PROPR_DESKTOP_WINDOWS_INSTALLED_APP=1" | Out-File -FilePath $env:GITHUB_ENV -Append + - name: Always clean signed Windows installed-app ownership + if: always() && matrix.platform == 'win32' && env.PROPR_WINDOWS_INSTALLED_APP_RUN_ID != '' + shell: pwsh + run: | + & apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 ` + -OwnershipManifest $env:PROPR_WINDOWS_INSTALLED_APP_MANIFEST ` + -Installer $env:PROPR_WINDOWS_INSTALLED_APP_INSTALLER ` + -ExpectedRunId $env:PROPR_WINDOWS_INSTALLED_APP_RUN_ID + - name: Launch packaged Linux application if: matrix.platform == 'linux' shell: bash diff --git a/Dockerfile.agent b/Dockerfile.agent index 118fd870b..72bf495b8 100644 --- a/Dockerfile.agent +++ b/Dockerfile.agent @@ -114,7 +114,7 @@ RUN npm install -g "@anthropic-ai/claude-code@${CLAUDE_CLI_VERSION}" \ FROM agent-base AS codex-cli -ARG CODEX_CLI_VERSION=0.146.0 +ARG CODEX_CLI_VERSION=0.151.0 USER root RUN npm install -g "@openai/codex@${CODEX_CLI_VERSION}" \ && npm cache clean --force \ @@ -192,7 +192,7 @@ RUN set -eu; \ FROM agent-base AS final ARG CLAUDE_CLI_VERSION=2.1.220 -ARG CODEX_CLI_VERSION=0.146.0 +ARG CODEX_CLI_VERSION=0.151.0 ARG ANTIGRAVITY_CLI_VERSION=1.1.13 ARG OPENCODE_CLI_VERSION=1.18.9 ARG VIBE_CLI_VERSION=2.23.1 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 44c9f1f66..97df88995 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -65,7 +65,10 @@ Electron `safeStorage` before they are written separately. If OS encryption is u `basic_text` backend—the app reports that state and refuses to persist credentials; there is no plaintext fallback. Profiles remain usable because they contain only a display label and validated API endpoint. -Opaque instance tokens are bound to profile ID plus normalized origin in encrypted main-process storage. Electron's +Opaque instance tokens and the strict-discovery public identity are bound to profile ID, normalized origin, and +credential generation in encrypted main-process storage. The renderer cannot provide or override the identity. +Launch, profile switch, pairing, revocation, and every Socket.IO reconnect perform credential-free strict discovery; +an absent, malformed, or changed identity sends no stored bearer and requires a fresh pairing generation. Electron's session request boundary strips renderer-supplied Authorization and Cookie headers from every HTTP(S) and WS(S) request, including inactive or mismatched profile origins, then injects the active bearer only for matching REST and Socket.IO requests. Set-Cookie is stripped from remote responses, so the packaged renderer has no parallel cookie diff --git a/apps/desktop/scripts/cleanup-installed-windows-app.ps1 b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 new file mode 100644 index 000000000..414edbefa --- /dev/null +++ b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 @@ -0,0 +1,1809 @@ +param( + [Parameter(Mandatory=$true)][string]$OwnershipManifest, + [Parameter(Mandatory=$true)][string]$Installer, + [Parameter(Mandatory=$true)][string]$ExpectedRunId, + [Parameter(Mandatory=$true)][string]$OwnershipReadyEvent, + [string]$FixtureRoot, + [switch]$FixtureValidationDiagnostic, + [switch]$FixtureEarlyInitializationChild +) + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +$ownerFileName = '.propr-installed-app-owner' +$ownerRegistryValue = 'ProPRInstalledAppOwner' +$cleanupFailed = $false +$manifestValidated = $false +$authorizedRunId = $null +$cleanupValidationPhase = 'HANDSHAKE' +$cleanupValidationPhases = @( + 'HANDSHAKE','FILE_AUTHORITY','UTF8_DECODE','JSON_PARSE','EXACT_KEY_SET', + 'BOOLEAN_TYPES','TRANSACTION_ENUM','SCHEMA_TYPE_STATE','RUN_ID_FORMAT', + 'INSTALLER_ENTRY_ID_FORMAT','INSTALLER_SHA256_FORMAT','INSTALLER_PRODUCT_CODE_FORMAT', + 'LIFETIME','RUN_ID','INSTALLER_PATH','FIXTURE_SCOPE','INITIAL_ACTIVE_MATCH', + 'INITIAL_INSTALLER_AUTHORITY_RECHECK','EMPTY_RECEIPT_WRITE' +) + +function Write-FixtureCleanupValidationPhase([string]$Phase) { + if (!$FixtureValidationDiagnostic -or !$FixtureRoot -or + $cleanupValidationPhases -cnotcontains $Phase) { + return + } + # Diagnostic success is deliberately silent; validation exit 20 and + # post-validation exit 21 emit this single bounded child-protocol line for + # supervisor parsing. + [Console]::Out.WriteLine( + 'CLEANUP_VALIDATION_PHASE:' + $Phase + ) + [Console]::Out.Flush() +} + +function Exit-CleanupHandshakeFailure { + Write-FixtureCleanupValidationPhase 'HANDSHAKE' + if ($FixtureValidationDiagnostic -and $FixtureRoot) { exit 20 } + exit 1 +} + +try { + if ($ExpectedRunId -notmatch '^[a-f0-9]{32}$') { Exit-CleanupHandshakeFailure } + if ($OwnershipReadyEvent -notmatch '^Local\\ProPRInstalledAppCleanup-[a-f0-9]{32}$') { + Exit-CleanupHandshakeFailure + } + $ownershipReady = [Threading.EventWaitHandle]::OpenExisting($OwnershipReadyEvent) + try { + if (!$ownershipReady.WaitOne(5000)) { Exit-CleanupHandshakeFailure } + } finally { + $ownershipReady.Dispose() + } +} catch { + Exit-CleanupHandshakeFailure +} + +# This fixture runs after the ownership release but before cold type loading so +# the controller test covers descendants created at the earliest worker phase. +if ($FixtureEarlyInitializationChild) { + try { + if (!$FixtureRoot) { exit 1 } + $fixtureEarlyRoot = (Resolve-Path -LiteralPath $FixtureRoot -ErrorAction Stop).Path + $fixtureHostPath = (Get-Process -Id $PID -ErrorAction Stop).Path + if ([IO.Path]::GetFileName($fixtureHostPath) -notin @('pwsh.exe', 'powershell.exe')) { + exit 1 + } + $fixtureChildStartInfo = [Diagnostics.ProcessStartInfo]::new() + $fixtureChildStartInfo.FileName = $fixtureHostPath + $fixtureChildStartInfo.UseShellExecute = $false + foreach ($argument in @( + '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', 'Start-Sleep -Seconds 300' + )) { + $fixtureChildStartInfo.ArgumentList.Add($argument) + } + $fixtureChild = [Diagnostics.Process]::new() + $fixtureChild.StartInfo = $fixtureChildStartInfo + if (!$fixtureChild.Start()) { exit 1 } + $fixtureStatePath = Join-Path $fixtureEarlyRoot 'workflow-cleanup-early-processes.json' + $fixtureStateTemporaryPath = "$fixtureStatePath.$PID.new" + $fixtureStateBytes = [Text.Encoding]::ASCII.GetBytes(( + [ordered]@{ WorkerPid = $PID; DescendantPid = $fixtureChild.Id } | + ConvertTo-Json -Compress + )) + $fixtureStateStream = [IO.FileStream]::new( + $fixtureStateTemporaryPath, + [IO.FileMode]::CreateNew, + [IO.FileAccess]::Write, + [IO.FileShare]::Read, + 4096, + [IO.FileOptions]::WriteThrough + ) + try { + $fixtureStateStream.Write($fixtureStateBytes, 0, $fixtureStateBytes.Length) + $fixtureStateStream.Flush($true) + } finally { + $fixtureStateStream.Dispose() + } + [IO.File]::Move($fixtureStateTemporaryPath, $fixtureStatePath) + Start-Sleep -Seconds 300 + } catch { + exit 1 + } +} + +Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +public static class ProPRDirectoryIdentity +{ + [StructLayout(LayoutKind.Sequential)] + private struct BY_HANDLE_FILE_INFORMATION + { + public uint FileAttributes; + public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime; + public uint VolumeSerialNumber; + public uint FileSizeHigh; + public uint FileSizeLow; + public uint NumberOfLinks; + public uint FileIndexHigh; + public uint FileIndexLow; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFile( + string path, uint access, uint share, IntPtr security, uint creation, + uint flags, IntPtr template); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetFileInformationByHandle( + SafeFileHandle handle, out BY_HANDLE_FILE_INFORMATION information); + + public static string ReadHandle(SafeFileHandle handle, bool expectDirectory) + { + if (handle == null || handle.IsInvalid) + throw new InvalidOperationException("file-system identity handle is invalid"); + BY_HANDLE_FILE_INFORMATION information; + if (!GetFileInformationByHandle(handle, out information)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "directory identity read failed"); + bool isDirectory = (information.FileAttributes & 0x10) != 0; + if ((information.FileAttributes & 0x400) != 0 || isDirectory != expectDirectory) + throw new InvalidOperationException("file-system object identity changed"); + return string.Format("{0:x8}{1:x8}{2:x8}", information.VolumeSerialNumber, + information.FileIndexHigh, information.FileIndexLow); + } + + public static string ReadEntry(string path, bool expectDirectory) + { + using (SafeFileHandle handle = CreateFile( + path, 0x80, 0x7, IntPtr.Zero, 3, 0x02200000, IntPtr.Zero)) + { + if (handle == null || handle.IsInvalid) + throw new Win32Exception(Marshal.GetLastWin32Error(), "directory identity open failed"); + return ReadHandle(handle, expectDirectory); + } + } + + public static string Read(string path) { return ReadEntry(path, true); } +} + +public static class ProPRAtomicFile +{ + private const uint MOVEFILE_REPLACE_EXISTING = 0x1; + private const uint MOVEFILE_WRITE_THROUGH = 0x8; + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true, + EntryPoint = "MoveFileExW")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool MoveFileExW( + string existingFileName, string newFileName, uint flags); + + public static void ReplaceSameDirectory(string temporaryPath, string destinationPath) + { + string temporaryFullPath = System.IO.Path.GetFullPath(temporaryPath); + string destinationFullPath = System.IO.Path.GetFullPath(destinationPath); + string temporaryDirectory = System.IO.Path.GetDirectoryName(temporaryFullPath); + string destinationDirectory = System.IO.Path.GetDirectoryName(destinationFullPath); + if (String.IsNullOrEmpty(temporaryDirectory) || + !String.Equals(temporaryDirectory, destinationDirectory, + StringComparison.OrdinalIgnoreCase) || + !System.IO.File.Exists(temporaryFullPath) || + !System.IO.File.Exists(destinationFullPath)) + { + throw new InvalidOperationException( + "atomic ownership receipt replacement precondition failed"); + } + + if (!MoveFileExW(temporaryFullPath, destinationFullPath, + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) + { + int error = Marshal.GetLastWin32Error(); + throw new Win32Exception(error, + "atomic ownership receipt replacement failed"); + } + } +} +'@ + +function Test-SamePath([string]$Left, [string]$Right) { + return [string]::Equals( + [IO.Path]::GetFullPath($Left).TrimEnd('\'), + [IO.Path]::GetFullPath($Right).TrimEnd('\'), + [StringComparison]::OrdinalIgnoreCase + ) +} + +function Resolve-CanonicalNonReparseDirectory([string]$Path, [string]$Label) { + if ([string]::IsNullOrWhiteSpace($Path) -or ![IO.Path]::IsPathRooted($Path)) { + throw "$Label path is invalid" + } + $fullPath = [IO.Path]::GetFullPath($Path).TrimEnd('\') + $pathRoot = [IO.Path]::GetPathRoot($fullPath) + if ([string]::IsNullOrWhiteSpace($pathRoot)) { throw "$Label path root is invalid" } + $rootItem = Get-Item -LiteralPath $pathRoot -Force -ErrorAction Stop + if (!$rootItem.PSIsContainer -or + ($rootItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "$Label path root is invalid" + } + $currentPath = $pathRoot + $components = @($fullPath.Substring($pathRoot.Length) -split '\\' | + Where-Object { $_.Length -ne 0 }) + foreach ($component in $components) { + $currentPath = Join-Path $currentPath $component + $item = Get-Item -LiteralPath $currentPath -Force -ErrorAction Stop + if (!$item.PSIsContainer -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "$Label path has invalid ancestry" + } + } + $resolved = (Resolve-Path -LiteralPath $fullPath -ErrorAction Stop).ProviderPath.TrimEnd('\') + if (![string]::Equals( + [IO.Path]::GetFullPath($resolved).TrimEnd('\'), + $fullPath, + [StringComparison]::OrdinalIgnoreCase + )) { + throw "$Label path is not canonical" + } + return $fullPath +} + +function Resolve-SystemProfilesDirectory { + $profileListPath = 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList' + $configured = [string](Get-ItemPropertyValue -LiteralPath $profileListPath ` + -Name 'ProfilesDirectory' -ErrorAction Stop) + $expanded = [Environment]::ExpandEnvironmentVariables($configured) + return Resolve-CanonicalNonReparseDirectory $expanded 'system profiles directory' +} + +function Resolve-ValidatedOwnedProfilePath([string]$LocalPath, [string]$UserName) { + if ($UserName -notmatch '^(?:propr-ci-|prpr)[a-f0-9]{8}$') { + throw 'owned profile username is invalid' + } + $profilesDirectory = Resolve-SystemProfilesDirectory + $canonicalLocalPath = Resolve-CanonicalNonReparseDirectory $LocalPath 'profile local' + $parent = Split-Path -Parent $canonicalLocalPath + $leaf = Split-Path -Leaf $canonicalLocalPath + if (!(Test-SamePath $parent $profilesDirectory) -or $leaf -cne $UserName) { + throw 'profile local path is not the exact owned direct child of ProfilesDirectory' + } + return $canonicalLocalPath +} + +function Test-PathWithin([string]$Path, [string]$Root) { + $fullPath = [IO.Path]::GetFullPath($Path) + $fullRoot = [IO.Path]::GetFullPath($Root).TrimEnd('\') + return $fullPath.StartsWith("$fullRoot\", [StringComparison]::OrdinalIgnoreCase) +} + +function Test-OwnerFile([string]$Directory, [string]$Token) { + if (!$Token -or !(Test-Path -LiteralPath $Directory -PathType Container)) { return $false } + $marker = Join-Path $Directory $ownerFileName + if (!(Test-Path -LiteralPath $marker -PathType Leaf)) { return $false } + $item = Get-Item -LiteralPath $marker -Force -ErrorAction Stop + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or $item.Length -gt 128) { + return $false + } + return ([IO.File]::ReadAllText($marker, [Text.Encoding]::ASCII) -ceq $Token) +} + +function Get-FileIdentity([string]$Path) { + if (!(Test-Path -LiteralPath $Path -PathType Leaf)) { return $null } + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + $item.Length -gt 65536) { + return $null + } + $stream = [IO.File]::Open( + $Path, + [IO.FileMode]::Open, + [IO.FileAccess]::Read, + [IO.FileShare]::Read + ) + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + return [BitConverter]::ToString($sha256.ComputeHash($stream)).Replace('-', '').ToLowerInvariant() + } finally { + $sha256.Dispose() + $stream.Dispose() + } +} + +function Get-DirectoryIdentity([string]$Path) { + if (!(Test-Path -LiteralPath $Path -PathType Container)) { return $null } + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { return $null } + return [ProPRDirectoryIdentity]::Read($item.FullName) +} + +function Get-FileSystemEntryIdentity([string]$Path, [bool]$Directory) { + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if ($item.PSIsContainer -ne $Directory -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'file-system object identity is invalid' + } + return [ProPRDirectoryIdentity]::ReadEntry($item.FullName, $Directory) +} + +function Get-FileSystemTreeIdentity([string]$Path) { + $root = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if (!$root.PSIsContainer -or + ($root.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'file-system tree root identity is invalid' + } + $rootPath = $root.FullName.TrimEnd('\') + $records = [Collections.Generic.List[string]]::new() + $records.Add(('D||{0}' -f (Get-FileSystemEntryIdentity $rootPath $true))) + foreach ($entry in @(Get-ChildItem -LiteralPath $rootPath -Recurse -Force -ErrorAction Stop)) { + if (($entry.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'file-system tree contains a reparse point' + } + $relativePath = $entry.FullName.Substring($rootPath.Length).TrimStart('\') + if (!$relativePath -or [IO.Path]::IsPathRooted($relativePath)) { + throw 'file-system tree relative path is invalid' + } + $kind = if ($entry.PSIsContainer) { 'D' } else { 'F' } + $relative = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($relativePath)) + $identity = Get-FileSystemEntryIdentity $entry.FullName ([bool]$entry.PSIsContainer) + $records.Add(('{0}|{1}|{2}' -f $kind, $relative, $identity)) + } + $recordArray = $records.ToArray() + [Array]::Sort($recordArray, [StringComparer]::Ordinal) + $payload = [Text.Encoding]::UTF8.GetBytes(($recordArray -join "`n")) + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + return [BitConverter]::ToString($sha256.ComputeHash($payload)).Replace('-', '').ToLowerInvariant() + } finally { + $sha256.Dispose() + } +} + +function Assert-MsiManagedFileSystemAuthority($Manifest) { + $installRootPath = if ($FixtureRoot) { $null } else { + Join-Path $env:ProgramFiles 'ProPR Desktop' + } + $installRoot = if ($FixtureRoot) { + @($Manifest.Directories | Where-Object { + $_.Owned -and [string]$_.Kind -ceq 'INSTALL_ROOT' + }) + } else { + @($Manifest.Directories | Where-Object { + $_.Owned -and [string]$_.Kind -ceq 'INSTALL_ROOT' -and + (Test-SamePath ([string]$_.Path) $installRootPath) + }) + } + $shortcutFolderPath = if ($FixtureRoot) { $null } else { + Join-Path ([Environment]::GetFolderPath( + [Environment+SpecialFolder]::CommonPrograms)) 'ProPR Desktop' + } + $shortcutFolder = if ($FixtureRoot) { + @($Manifest.Directories | Where-Object { + $_.Owned -and [string]$_.Kind -ceq 'SHORTCUT_FOLDER' + }) + } else { + @($Manifest.Directories | Where-Object { + $_.Owned -and [string]$_.Kind -ceq 'SHORTCUT_FOLDER' -and + (Test-SamePath ([string]$_.Path) $shortcutFolderPath) + }) + } + $shortcutPath = if ($FixtureRoot) { $null } else { + Join-Path $shortcutFolderPath 'ProPR Desktop.lnk' + } + $shortcut = if ($FixtureRoot) { + @($Manifest.Files | Where-Object { + $_.Owned -and [string]$_.Kind -ceq 'SHORTCUT_FILE' + }) + } else { + @($Manifest.Files | Where-Object { + $_.Owned -and [string]$_.Kind -ceq 'SHORTCUT_FILE' -and + (Test-SamePath ([string]$_.Path) $shortcutPath) + }) + } + + foreach ($candidate in @( + [PSCustomObject]@{ + Records = $installRoot; Path = $installRootPath; Directory = $true; Tree = $true + }, + [PSCustomObject]@{ + Records = $shortcutFolder; Path = $shortcutFolderPath; Directory = $true; Tree = $true + }, + [PSCustomObject]@{ + Records = $shortcut; Path = $shortcutPath; Directory = $false; Tree = $false + } + )) { + $candidatePath = if ($FixtureRoot -and $candidate.Records.Count -eq 1) { + [string]$candidate.Records[0].Path + } else { [string]$candidate.Path } + if ($candidate.Records.Count -ne 1) { + throw 'MSI-managed file-system authority is missing or ambiguous' + } + if (!$candidatePath -or !(Test-Path -LiteralPath $candidatePath)) { continue } + $record = $candidate.Records[0] + $entryIdentity = if ($candidate.Directory) { + [string]$record.Identity + } else { [string]$record.EntryIdentity } + if ([bool]$record.Provisional -or + $entryIdentity -notmatch '^[a-f0-9]{24}$' -or + (Get-FileSystemEntryIdentity $candidatePath $candidate.Directory) -cne + $entryIdentity) { + throw 'MSI-managed file-system object identity does not match' + } + if ($candidate.Tree) { + if ([string]$record.TreeIdentity -notmatch '^[a-f0-9]{64}$' -or + (Get-FileSystemTreeIdentity $candidatePath) -cne + [string]$record.TreeIdentity) { + throw 'MSI-managed file-system tree identity does not match' + } + } elseif ([string]$record.Identity -notmatch '^[a-f0-9]{64}$' -or + (Get-FileIdentity $candidatePath) -cne [string]$record.Identity) { + throw 'MSI-managed shortcut content identity does not match' + } + } +} + +function Get-RegistryTreeIdentity([string]$Path) { + if (!(Test-Path -LiteralPath $Path)) { return $null } + $root = Get-Item -LiteralPath $Path -ErrorAction Stop + $records = [Collections.Generic.List[string]]::new() + $pending = [Collections.Generic.Queue[object]]::new() + $pending.Enqueue([PSCustomObject]@{ Key = $root; Relative = '' }) + while ($pending.Count -ne 0) { + $entry = $pending.Dequeue() + $records.Add(('K|{0}' -f [Convert]::ToBase64String( + [Text.Encoding]::UTF8.GetBytes([string]$entry.Relative)))) + foreach ($valueName in @($entry.Key.GetValueNames() | Sort-Object -CaseSensitive)) { + $value = $entry.Key.GetValue( + $valueName, + $null, + [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames + ) + $valueBytes = if ($value -is [byte[]]) { + $value + } elseif ($value -is [string[]]) { + [Text.Encoding]::UTF8.GetBytes(($value | ConvertTo-Json -Compress)) + } else { + [Text.Encoding]::UTF8.GetBytes([Convert]::ToString( + $value, + [Globalization.CultureInfo]::InvariantCulture + )) + } + $records.Add(('V|{0}|{1}|{2}' -f + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([string]$valueName)), + $entry.Key.GetValueKind($valueName).ToString(), + [Convert]::ToBase64String($valueBytes))) + } + foreach ($child in @(Get-ChildItem -LiteralPath $entry.Key.PSPath -ErrorAction Stop | + Sort-Object -Property PSChildName -CaseSensitive)) { + $relative = if ($entry.Relative) { + '{0}\{1}' -f $entry.Relative, $child.PSChildName + } else { [string]$child.PSChildName } + $pending.Enqueue([PSCustomObject]@{ Key = $child; Relative = $relative }) + } + } + $payload = [Text.Encoding]::UTF8.GetBytes(($records -join "`n")) + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + return [BitConverter]::ToString($sha256.ComputeHash($payload)).Replace('-', '').ToLowerInvariant() + } + finally { $sha256.Dispose() } +} + +function Get-InstallerSha256([string]$Path) { + $stream = [IO.File]::Open( + $Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + return [BitConverter]::ToString($sha256.ComputeHash($stream)).Replace('-', '').ToLowerInvariant() + } finally { + $sha256.Dispose() + $stream.Dispose() + } +} + +function Assert-InstallerArtifactAuthority($Manifest) { + $path = [string]$Manifest.InstallerPath + if ([string]$Manifest.InstallerEntryIdentity -notmatch '^[a-f0-9]{24}$' -or + [string]$Manifest.InstallerSha256 -notmatch '^[a-f0-9]{64}$' -or + [string]$Manifest.InstallerProductCode -notmatch + '^\{[A-F0-9]{8}(?:-[A-F0-9]{4}){3}-[A-F0-9]{12}\}$' -or + (Get-FileSystemEntryIdentity $path $false) -cne + [string]$Manifest.InstallerEntryIdentity -or + (Get-InstallerSha256 $path) -cne [string]$Manifest.InstallerSha256) { + throw 'installer artifact no longer matches durable authority' + } +} + +function Assert-MsiProductIsUnregistered([string]$ProductCode) { + $installerCom = $null + try { + if ($ProductCode -notmatch '^\{[A-F0-9]{8}(?:-[A-F0-9]{4}){3}-[A-F0-9]{12}\}$') { + throw 'MSI product identity is invalid' + } + $installerCom = New-Object -ComObject WindowsInstaller.Installer + if ([int]$installerCom.ProductState($ProductCode) -ne -1) { + throw 'Windows Installer product registration is not at the clean baseline' + } + } finally { + if ($null -ne $installerCom -and + [Runtime.InteropServices.Marshal]::IsComObject($installerCom)) { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($installerCom) + } + } +} + +function Assert-MsiRolledBackCleanBaseline($Manifest) { + if ($FixtureRoot -or [string]$Manifest.MsiTransactionState -cne 'ROLLED_BACK_CLEAN') { + return + } + foreach ($path in @( + (Join-Path $env:ProgramFiles 'ProPR Desktop'), + (Join-Path ([Environment]::GetFolderPath( + [Environment+SpecialFolder]::CommonPrograms)) 'ProPR Desktop'), + 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr', + 'Registry::HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\CurrentVersion\App Paths\propr-desktop.exe' + )) { + if (Test-Path -LiteralPath $path) { + throw 'MSI rollback did not restore the exact clean baseline' + } + } + if (@($Manifest.Directories).Count -ne 0 -or @($Manifest.Files).Count -ne 0 -or + @($Manifest.RegistryKeys).Count -ne 0) { + throw 'MSI rollback receipt contains file-system or machine-registry authority' + } + $installedRecords = @($Manifest.RegistryValues) + if ($installedRecords.Count -ne 1) { + throw 'MSI rollback current-user baseline receipt is missing or ambiguous' + } + $record = $installedRecords[0] + $current = Get-RegistryValueSnapshot ([string]$record.Path) ([string]$record.Name) + $matchesBaseline = if ([bool]$record.BaselineValueExisted) { + $current.Exists -and $current.Kind -ceq [string]$record.BaselineValueKind -and + $current.Data -ceq [string]$record.BaselineValueData + } else { !$current.Exists } + $keyMatchesBaseline = (Test-Path -LiteralPath ([string]$record.Path)) -eq + [bool]$record.BaselineKeyExisted + if (!$matchesBaseline -or !$keyMatchesBaseline) { + throw 'MSI rollback did not restore the exact current-user baseline' + } + Assert-InstallerArtifactAuthority $Manifest + Assert-MsiProductIsUnregistered ([string]$Manifest.InstallerProductCode) +} + +function Convert-RegistryValueToBytes( + [Microsoft.Win32.RegistryValueKind]$Kind, + $Value +) { + switch ($Kind) { + 'DWord' { return [BitConverter]::GetBytes([int32]$Value) } + 'QWord' { return [BitConverter]::GetBytes([int64]$Value) } + 'String' { return [Text.Encoding]::UTF8.GetBytes([string]$Value) } + 'ExpandString' { return [Text.Encoding]::UTF8.GetBytes([string]$Value) } + 'MultiString' { + return [Text.Encoding]::UTF8.GetBytes( + (ConvertTo-Json -InputObject @([string[]]$Value) -Compress)) + } + 'Binary' { return [byte[]]$Value } + 'None' { return [byte[]]$Value } + default { throw 'registry value kind is unsupported' } + } +} + +function Get-RegistryValueSnapshot([string]$Path, [string]$Name) { + if (!(Test-Path -LiteralPath $Path)) { + return [PSCustomObject]@{ Exists = $false; Kind = $null; Data = $null } + } + $key = Get-Item -LiteralPath $Path -ErrorAction Stop + if (@($key.GetValueNames()) -cnotcontains $Name) { + return [PSCustomObject]@{ Exists = $false; Kind = $null; Data = $null } + } + $kind = $key.GetValueKind($Name) + $value = $key.GetValue( + $Name, + $null, + [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames + ) + return [PSCustomObject]@{ + Exists = $true + Kind = $kind.ToString() + Data = [Convert]::ToBase64String((Convert-RegistryValueToBytes $kind $value)) + } +} + +function Test-MsiInstalledValue([string]$Path, [string]$Name) { + $snapshot = Get-RegistryValueSnapshot $Path $Name + return $snapshot.Exists -and $snapshot.Kind -ceq 'DWord' -and + $snapshot.Data -ceq [Convert]::ToBase64String([BitConverter]::GetBytes([int32]1)) +} + +function Test-RegistryValueIdentity($Record, $Snapshot) { + return $Snapshot.Exists -and + [string]$Record.IdentityValueKind -in @( + 'DWord','QWord','String','ExpandString','MultiString','Binary','None' + ) -and + [string]$Record.IdentityValueData -match '^[A-Za-z0-9+/]*={0,2}$' -and + $Snapshot.Kind -ceq [string]$Record.IdentityValueKind -and + $Snapshot.Data -ceq [string]$Record.IdentityValueData +} + +function Test-AllowedFileSystemPath([string]$Kind, [string]$Path) { + if ($FixtureRoot) { return Test-PathWithin $Path $FixtureRoot } + $installRoot = Join-Path $env:ProgramFiles 'ProPR Desktop' + $commonPrograms = [Environment]::GetFolderPath([Environment+SpecialFolder]::CommonPrograms) + $shortcutFolder = Join-Path $commonPrograms 'ProPR Desktop' + $shortcut = Join-Path $shortcutFolder 'ProPR Desktop.lnk' + if ($Kind -eq 'INSTALL_ROOT') { return Test-SamePath $Path $installRoot } + if ($Kind -eq 'SHORTCUT_FOLDER') { return Test-SamePath $Path $shortcutFolder } + if ($Kind -eq 'SHORTCUT_FILE') { return Test-SamePath $Path $shortcut } + if ($Kind -eq 'SMOKE_DATA') { + $machineTempValue = [Environment]::GetEnvironmentVariable( + 'TEMP', [EnvironmentVariableTarget]::Machine) + if (!$machineTempValue) { return $false } + $machineTemp = [Environment]::ExpandEnvironmentVariables($machineTempValue) + return (Split-Path -Leaf $Path) -match '^propr-desktop-smoke-[a-f0-9]{32}$' -and + (Test-SamePath (Split-Path -Parent $Path) $machineTemp) + } + return $false +} + +function Assert-SmokeAccessControl($Item, $Record, [bool]$Root) { + $userSid = [string]$Record.UserSid + $creatorSid = [string]$Record.CreatorSid + $rootOwnerSid = [string]$Record.RootOwnerSid + if ($userSid -notmatch '^S-\d+(?:-\d+)+$' -or + $creatorSid -notmatch '^S-\d+(?:-\d+)+$' -or + $rootOwnerSid -cne 'S-1-5-32-544') { + throw 'smoke user-data manifest security authority is invalid' + } + $systemSid = 'S-1-5-18' + $expectedAccessSids = @($userSid, $systemSid, $rootOwnerSid) | Sort-Object -Unique + if ($expectedAccessSids.Count -ne 3) { + throw 'smoke user-data manifest security authority is invalid' + } + $acl = Get-Acl -LiteralPath $Item.FullName -ErrorAction Stop + $ownerSid = $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value + $allowedOwnerSids = @($userSid, $creatorSid, $rootOwnerSid) | Sort-Object -Unique + if ($allowedOwnerSids -cnotcontains $ownerSid) { + throw 'smoke user-data object owner is not authorized' + } + $rules = @($acl.Access) + $actualAccessSids = @($rules | ForEach-Object { + ($_.IdentityReference.Translate([Security.Principal.SecurityIdentifier])).Value + }) | Sort-Object -Unique + $fullControl = [Security.AccessControl.FileSystemRights]::FullControl + $expectedInheritance = [Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit' + $invalidRules = if ($Root) { + @($rules | Where-Object { + $_.IsInherited -or + $_.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow -or + ($_.FileSystemRights -band $fullControl) -ne $fullControl -or + $_.InheritanceFlags -ne $expectedInheritance -or + $_.PropagationFlags -ne [Security.AccessControl.PropagationFlags]::None + }) + } else { + $inheritedFlags = if ($Item.PSIsContainer) { + $expectedInheritance + } else { [Security.AccessControl.InheritanceFlags]::None } + @($rules | Where-Object { + !$_.IsInherited -or + $_.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow -or + ($_.FileSystemRights -band $fullControl) -ne $fullControl -or + $_.InheritanceFlags -ne $inheritedFlags -or + $_.PropagationFlags -ne [Security.AccessControl.PropagationFlags]::None + }) + } + if (($Root -and (!$acl.AreAccessRulesProtected -or $ownerSid -cne $rootOwnerSid)) -or + (!$Root -and $acl.AreAccessRulesProtected) -or + $rules.Count -ne 3 -or $invalidRules.Count -ne 0 -or + @(Compare-Object $expectedAccessSids $actualAccessSids).Count -ne 0) { + throw 'smoke user-data object ACL is not authorized' + } +} + +function Assert-OwnedSmokeRoot($Record) { + $path = [IO.Path]::GetFullPath([string]$Record.Path) + if (!(Test-AllowedFileSystemPath 'SMOKE_DATA' $path) -or + [string]$Record.Token -notmatch '^[a-f0-9]{32}$') { + throw 'smoke user-data cleanup scope is invalid' + } + $item = Get-Item -LiteralPath $path -Force -ErrorAction Stop + if (!$item.PSIsContainer -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'smoke user-data root identity is invalid' + } + $markerPath = Join-Path $path $ownerFileName + $marker = Get-Item -LiteralPath $markerPath -Force -ErrorAction Stop + if (!($marker -is [IO.FileInfo]) -or + ($marker.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'smoke user-data ownership token does not match' + } + $markerIdentity = Get-FileSystemEntryIdentity $marker.FullName $false + $markerStream = [IO.File]::Open( + $markerPath, + [IO.FileMode]::Open, + [IO.FileAccess]::Read, + [IO.FileShare]::Read + ) + try { + if ($markerStream.Length -le 0 -or $markerStream.Length -gt 128) { + throw 'smoke user-data ownership token does not match' + } + $markerBytes = [byte[]]::new([int]$markerStream.Length) + $markerOffset = 0 + while ($markerOffset -lt $markerBytes.Length) { + $markerRead = $markerStream.Read( + $markerBytes, $markerOffset, $markerBytes.Length - $markerOffset) + if ($markerRead -eq 0) { throw 'smoke user-data ownership token does not match' } + $markerOffset += $markerRead + } + if ($markerStream.ReadByte() -ne -1 -or + [Text.Encoding]::ASCII.GetString($markerBytes) -cne [string]$Record.Token) { + throw 'smoke user-data ownership token does not match' + } + } finally { + $markerStream.Dispose() + } + Assert-SmokeAccessControl $item $Record $true + Assert-SmokeAccessControl $marker $Record $false + if ((Get-FileSystemEntryIdentity $marker.FullName $false) -cne $markerIdentity) { + throw 'smoke user-data ownership token identity changed' + } + return $item +} + +function Resolve-SmokeDirectoryAuthority($Record, $Manifest, [string]$ManifestPath) { + if (!$Record.Owned -or [string]$Record.Kind -cne 'SMOKE_DATA') { return $false } + $recordKeys = @($Record.PSObject.Properties | ForEach-Object { $_.Name }) + $expectedKeys = @( + 'Kind','Path','Owned','Token','Identity','Provisional', + 'UserSid','CreatorSid','RootOwnerSid' + ) + if ($recordKeys.Count -ne $expectedKeys.Count -or + @($expectedKeys | Where-Object { $recordKeys -cnotcontains $_ }).Count -ne 0 -or + $Record.Owned -isnot [bool] -or $Record.Provisional -isnot [bool] -or + [string]$Record.Token -notmatch '^[a-f0-9]{32}$' -or + [string]$Record.UserSid -notmatch '^S-\d+(?:-\d+)+$' -or + [string]$Record.CreatorSid -notmatch '^S-\d+(?:-\d+)+$' -or + [string]$Record.RootOwnerSid -cne 'S-1-5-32-544' -or + (![bool]$Record.Provisional -and [string]$Record.Identity -notmatch '^[a-f0-9]{24}$') -or + ([bool]$Record.Provisional -and $null -ne $Record.Identity)) { + throw 'smoke user-data manifest authority is invalid' + } + $ownedUsers = @($Manifest.Users | Where-Object { $_.Owned }) + if ($ownedUsers.Count -ne 1 -or [bool]$ownedUsers[0].Provisional -or + [string]$ownedUsers[0].Sid -cne [string]$Record.UserSid) { + throw 'smoke user-data SID is not the exact run-owned user SID' + } + if (!(Test-Path -LiteralPath ([string]$Record.Path))) { return $false } + $root = Assert-OwnedSmokeRoot $Record + $identity = Get-FileSystemEntryIdentity $root.FullName $true + if ([bool]$Record.Provisional) { + $Record.Identity = $identity + $Record.Provisional = $false + Write-DurableOwnershipManifest $ManifestPath $Manifest + return $true + } + if ([string]$Record.Identity -cne $identity) { + throw 'smoke user-data root identity does not match' + } + return $false +} + +function Remove-OwnedSmokeDirectory($Record) { + if (!$Record.Owned -or !(Test-Path -LiteralPath ([string]$Record.Path))) { return } + if ([bool]$Record.Provisional) { + throw 'provisional smoke user-data authority was not durably promoted' + } + $root = Assert-OwnedSmokeRoot $Record + if ((Get-FileSystemEntryIdentity $root.FullName $true) -cne [string]$Record.Identity) { + throw 'smoke user-data root identity does not match' + } + $rootPath = $root.FullName.TrimEnd('\') + $pending = [Collections.Generic.Queue[object]]::new() + $pending.Enqueue([PSCustomObject]@{ + Path = $root.FullName + Identity = [string]$Record.Identity + Root = $true + }) + $entries = [Collections.Generic.List[object]]::new() + while ($pending.Count -ne 0) { + $queuedDirectory = $pending.Dequeue() + $directory = Get-Item -LiteralPath $queuedDirectory.Path -Force -ErrorAction Stop + Assert-SmokeAccessControl $directory $Record ([bool]$queuedDirectory.Root) + if ((Get-FileSystemEntryIdentity $directory.FullName $true) -cne + [string]$queuedDirectory.Identity) { + throw 'smoke user-data directory identity changed during traversal' + } + foreach ($child in @(Get-ChildItem -LiteralPath $directory.FullName -Force -ErrorAction Stop)) { + if ($entries.Count -ge 50000) { throw 'smoke user-data cleanup entry bound was exceeded' } + $childPath = [IO.Path]::GetFullPath($child.FullName) + if (!$childPath.StartsWith("$rootPath\", [StringComparison]::OrdinalIgnoreCase) -or + ($child.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'smoke user-data descendant scope is invalid' + } + Assert-SmokeAccessControl $child $Record $false + $identity = Get-FileSystemEntryIdentity $childPath ([bool]$child.PSIsContainer) + $entries.Add([PSCustomObject]@{ + Path = $childPath + Directory = [bool]$child.PSIsContainer + Identity = $identity + }) + if ($child.PSIsContainer) { + $pending.Enqueue([PSCustomObject]@{ + Path = $childPath + Identity = $identity + Root = $false + }) + } + } + } + + foreach ($entry in @($entries | Where-Object { !$_.Directory })) { + $item = Get-Item -LiteralPath $entry.Path -Force -ErrorAction Stop + Assert-SmokeAccessControl $item $Record $false + if ((Get-FileSystemEntryIdentity $entry.Path $false) -cne [string]$entry.Identity) { + throw 'smoke user-data file identity changed during cleanup' + } + Remove-Item -LiteralPath $entry.Path -Force -ErrorAction Stop + } + foreach ($entry in @($entries | Where-Object { $_.Directory } | + Sort-Object { ([string]$_.Path).Length } -Descending)) { + $item = Get-Item -LiteralPath $entry.Path -Force -ErrorAction Stop + Assert-SmokeAccessControl $item $Record $false + if ((Get-FileSystemEntryIdentity $entry.Path $true) -cne [string]$entry.Identity -or + @(Get-ChildItem -LiteralPath $entry.Path -Force -ErrorAction Stop).Count -ne 0) { + throw 'smoke user-data directory identity changed or is not empty' + } + Remove-Item -LiteralPath $entry.Path -Force -ErrorAction Stop + } + $root = Get-Item -LiteralPath $rootPath -Force -ErrorAction Stop + if (!$root.PSIsContainer -or + ($root.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'smoke user-data root identity changed during cleanup' + } + Assert-SmokeAccessControl $root $Record $true + if ((Get-FileSystemEntryIdentity $root.FullName $true) -cne [string]$Record.Identity -or + @(Get-ChildItem -LiteralPath $root.FullName -Force -ErrorAction Stop).Count -ne 0) { + throw 'smoke user-data root changed or is not empty' + } + Remove-Item -LiteralPath $root.FullName -Force -ErrorAction Stop +} + +function Remove-OwnedDirectory($Record) { + if (!$Record.Owned) { return } + if ([string]$Record.Kind -ceq 'SMOKE_DATA') { + Remove-OwnedSmokeDirectory $Record + return + } + $path = [string]$Record.Path + $kind = [string]$Record.Kind + if (!(Test-AllowedFileSystemPath $kind $path)) { throw 'directory cleanup scope is invalid' } + if (!(Test-Path -LiteralPath $path)) { return } + $item = Get-Item -LiteralPath $path -Force -ErrorAction Stop + if (!$item.PSIsContainer -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'owned directory identity is invalid' + } + if ([bool]$Record.Provisional) { + throw 'provisional directory evidence cannot authorize manual cleanup' + } + $tokenMatches = Test-OwnerFile $path ([string]$Record.Token) + $identityMatches = [string]$Record.Identity -match '^[a-f0-9]{24}$' -and + (Get-DirectoryIdentity $path) -ceq [string]$Record.Identity + if (!$tokenMatches -and !$identityMatches) { + throw 'owned directory identity does not match' + } + $markerPath = Join-Path $path $ownerFileName + $children = @(Get-ChildItem -LiteralPath $path -Force -ErrorAction Stop) + $unexpectedChildren = @($children | Where-Object { + ![string]::Equals($_.FullName, $markerPath, [StringComparison]::OrdinalIgnoreCase) + }) + if ($unexpectedChildren.Count -ne 0) { + throw 'owned directory contains an unexpected descendant' + } + if ($children.Count -ne 0) { + if (!$tokenMatches -or $children.Count -ne 1) { + throw 'owned directory marker identity does not match' + } + Remove-Item -LiteralPath $markerPath -Force -ErrorAction Stop + } + if (@(Get-ChildItem -LiteralPath $path -Force -ErrorAction Stop).Count -ne 0) { + throw 'owned directory is not empty' + } + Remove-Item -LiteralPath $path -Force -ErrorAction Stop + if (Test-Path -LiteralPath $path) { throw 'owned directory cleanup did not complete' } +} + +function Remove-OwnedFile($Record) { + if (!$Record.Owned) { return } + $path = [string]$Record.Path + $kind = [string]$Record.Kind + if (!(Test-AllowedFileSystemPath $kind $path)) { throw 'file cleanup scope is invalid' } + if (!(Test-Path -LiteralPath $path)) { return } + $item = Get-Item -LiteralPath $path -Force -ErrorAction Stop + if (!($item -is [IO.FileInfo]) -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'owned file identity is invalid' + } + if ([bool]$Record.Provisional) { + throw 'provisional file evidence cannot authorize manual cleanup' + } + if ([string]$Record.Identity -notmatch '^[a-f0-9]{64}$' -or + (Get-FileIdentity $path) -cne [string]$Record.Identity) { + throw 'owned file content identity does not match' + } + if ([string]$Record.EntryIdentity -notmatch '^[a-f0-9]{24}$' -or + (Get-FileSystemEntryIdentity $path $false) -cne [string]$Record.EntryIdentity) { + throw 'owned file entry identity does not match' + } + Remove-Item -LiteralPath $path -Force -ErrorAction Stop + if (Test-Path -LiteralPath $path) { throw 'owned file cleanup did not complete' } +} + +function Remove-OwnedRegistryKey($Record) { + if (!$Record.Owned) { return } + $path = [string]$Record.Path + $kind = [string]$Record.Kind + $productionPaths = @{ + PROTOCOL = 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr' + APP_PATH = 'Registry::HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\CurrentVersion\App Paths\propr-desktop.exe' + } + if ($FixtureRoot) { + $expectedPath = "Registry::HKEY_LOCAL_MACHINE\Software\ProPRSupervisorFixture\$authorizedRunId\owned" + if (![string]::Equals($path, $expectedPath, [StringComparison]::OrdinalIgnoreCase)) { + throw 'registry cleanup scope is invalid' + } + } elseif (!$productionPaths.ContainsKey($kind) -or + ![string]::Equals($path, $productionPaths[$kind], [StringComparison]::OrdinalIgnoreCase)) { + throw 'registry cleanup scope is invalid' + } + if (!(Test-Path -LiteralPath $path)) { return } + if ([bool]$Record.Provisional) { + throw 'provisional registry evidence cannot authorize manual cleanup' + } + if ($FixtureRoot) { + $token = Get-ItemPropertyValue -LiteralPath $path -Name $ownerRegistryValue -ErrorAction Stop + if ([string]$token -cne [string]$Record.Token) { throw 'owned registry token does not match' } + } elseif ([string]$Record.Identity -notmatch '^[a-f0-9]{64}$' -or + (Get-RegistryTreeIdentity $path) -cne [string]$Record.Identity) { + throw 'owned registry identity does not match' + } + Remove-Item -LiteralPath $path -Recurse -Force -ErrorAction Stop + if (Test-Path -LiteralPath $path) { throw 'owned registry cleanup did not complete' } + if ($FixtureRoot) { + $runRoot = Split-Path -Parent $path + if ((Test-Path -LiteralPath $runRoot) -and + @(Get-ChildItem -LiteralPath $runRoot -Force -ErrorAction Stop).Count -eq 0) { + Remove-Item -LiteralPath $runRoot -Force -ErrorAction Stop + } + } +} + +function Restore-OwnedRegistryValue($Record) { + if (!$Record.Owned) { return } + $path = [string]$Record.Path + $name = [string]$Record.Name + if ([string]$Record.Kind -cne 'HKCU_INSTALLED' -or + ![string]::Equals( + $path, + 'Registry::HKEY_CURRENT_USER\Software\ProPR\Desktop', + [StringComparison]::OrdinalIgnoreCase + ) -or $name -cne 'installed') { + throw 'registry value cleanup scope is invalid' + } + + $current = Get-RegistryValueSnapshot $path $name + $baselineValueExists = [bool]$Record.BaselineValueExisted + $baselineKind = [string]$Record.BaselineValueKind + $baselineData = [string]$Record.BaselineValueData + $matchesBaseline = $baselineValueExists -and $current.Exists -and + $current.Kind -ceq $baselineKind -and $current.Data -ceq $baselineData + if ([bool]$Record.Provisional -and $current.Exists -and !$matchesBaseline) { + throw 'provisional registry evidence cannot authorize manual cleanup' + } + if ($current.Exists -and !$matchesBaseline -and + !(Test-RegistryValueIdentity $Record $current)) { + throw 'registry value ownership changed' + } + + if ($baselineValueExists) { + if (!(Test-Path -LiteralPath $path)) { + [void](New-Item -Path $path -Force -ErrorAction Stop) + } + if (!$matchesBaseline) { + $kind = [Enum]::Parse([Microsoft.Win32.RegistryValueKind], $baselineKind, $false) + $bytes = [Convert]::FromBase64String($baselineData) + $value = switch ($kind) { + 'DWord' { [BitConverter]::ToInt32($bytes, 0); break } + 'QWord' { [BitConverter]::ToInt64($bytes, 0); break } + 'String' { [Text.Encoding]::UTF8.GetString($bytes); break } + 'ExpandString' { [Text.Encoding]::UTF8.GetString($bytes); break } + 'MultiString' { + @([string[]](ConvertFrom-Json -InputObject ([Text.Encoding]::UTF8.GetString($bytes)))) + break + } + 'Binary' { $bytes; break } + 'None' { $bytes; break } + default { throw 'registry baseline kind is unsupported' } + } + (Get-Item -LiteralPath $path -ErrorAction Stop).SetValue($name, $value, $kind) + } + } elseif ($current.Exists) { + Remove-ItemProperty -LiteralPath $path -Name $name -Force -ErrorAction Stop + } + + if ([bool]$Record.KeyCreatedByRun -and (Test-Path -LiteralPath $path)) { + $key = Get-Item -LiteralPath $path -ErrorAction Stop + if (@($key.GetValueNames()).Count -eq 0 -and @($key.GetSubKeyNames()).Count -eq 0) { + Remove-Item -LiteralPath $path -Force -ErrorAction Stop + } + } + + $after = Get-RegistryValueSnapshot $path $name + if ($baselineValueExists) { + if (!$after.Exists -or $after.Kind -cne $baselineKind -or $after.Data -cne $baselineData) { + throw 'registry baseline restoration did not complete' + } + } elseif ($after.Exists) { + throw 'owned registry value cleanup did not complete' + } +} + +function Write-DurableOwnershipManifest([string]$Path, $Manifest) { + $temporaryPath = "$Path.new" + $replacementCompleted = $false + try { + $bytes = [Text.Encoding]::UTF8.GetBytes(( + $Manifest | ConvertTo-Json -Depth 6 -Compress + )) + $stream = [IO.FileStream]::new( + $temporaryPath, + [IO.FileMode]::Create, + [IO.FileAccess]::Write, + [IO.FileShare]::None, + 4096, + [IO.FileOptions]::WriteThrough + ) + try { + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + } finally { + $stream.Dispose() + } + if ($PSVersionTable.PSEdition -ceq 'Core') { + # Native pwsh provides the atomic same-directory overwrite overload. + [IO.File]::Move($temporaryPath, $Path, $true) + } else { + # .NET Framework File.Replace is unsuitable for the real PS5.1 reader + # flow. Use one same-directory Windows rename with no cross-volume-copy + # flag, replacing the existing pathname and waiting for durable completion. + [ProPRAtomicFile]::ReplaceSameDirectory($temporaryPath, $Path) + } + $replacementCompleted = $true + } finally { + if (!$replacementCompleted) { [IO.File]::Delete($temporaryPath) } + } +} + +function Write-EmptyOwnershipReceipt([string]$Path, $Manifest) { + # Build the final receipt independently. If serialization or replacement + # fails, the caller and canonical pathname both retain ACTIVE authority. + $emptyReceipt = $Manifest.PSObject.Copy() + $emptyReceipt.State = 'EMPTY' + $emptyReceipt.BaselineClean = $false + $emptyReceipt.InstallAttempted = $false + $emptyReceipt.MsiTransactionState = 'NONE' + $emptyReceipt.Directories = @() + $emptyReceipt.Files = @() + $emptyReceipt.RegistryKeys = @() + $emptyReceipt.RegistryValues = @() + $emptyReceipt.Users = @() + $emptyReceipt.Profiles = @() + Write-DurableOwnershipManifest $Path $emptyReceipt +} + +function Resolve-ProvisionalOwnedUser($Record) { + if (!$Record.Owned -or [string]$Record.Sid -match '^S-\d+(?:-\d+)+$') { + return $false + } + if (!$Record.Provisional) { throw 'owned user SID is invalid' } + $name = [string]$Record.Name + $ownershipMarker = [string]$Record.OwnershipMarker + $user = Get-LocalUser -Name $name -ErrorAction SilentlyContinue + if ($null -eq $user) { return $false } + if ($ownershipMarker -notmatch '^prpr-own-[a-f0-9]{32}$' -or + [string]$user.Description -cne $ownershipMarker -or + [string]$user.SID.Value -notmatch '^S-\d+(?:-\d+)+$') { + throw 'provisional local-user ownership marker does not match' + } + $Record.Sid = [string]$user.SID.Value + $Record.Provisional = $false + return $true +} + +function Promote-UncapturedOwnedProfiles($UserRecord, $Manifest) { + if (!$UserRecord.Owned) { return $false } + $name = [string]$UserRecord.Name + $sid = [string]$UserRecord.Sid + $ownershipMarker = [string]$UserRecord.OwnershipMarker + if ($sid -notmatch '^S-\d+(?:-\d+)+$' -and $UserRecord.Provisional) { + return $false + } + if ($name -notmatch '^(?:propr-ci-|prpr)[a-f0-9]{8}$' -or + $sid -notmatch '^S-\d+(?:-\d+)+$' -or + $ownershipMarker -notmatch '^prpr-own-[a-f0-9]{32}$') { + throw 'profile promotion identity is invalid' + } + $durableProfiles = @($Manifest.Profiles | Where-Object { + $_.Owned -and [string]$_.Sid -ceq $sid + }) + if ($durableProfiles.Count -ne 0) { return $false } + + $profiles = @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop | + Where-Object { $_.SID -ceq $sid }) + if ($profiles.Count -eq 0) { return $false } + + # An absent profile record can be promoted only while the exact run-created + # account still authenticates both the marker and SID. A durable path record + # is published by the caller before any profile deletion is attempted. + $user = Get-LocalUser -Name $name -ErrorAction SilentlyContinue + if ($null -eq $user -or [string]$user.Description -cne $ownershipMarker -or + [string]$user.SID.Value -cne $sid) { + throw 'uncaptured profile lacks authenticated marker and SID authority' + } + $promoted = @() + foreach ($profile in $profiles) { + if ([string]$profile.SID -cne $sid) { + throw 'profile SID changed during ownership promotion' + } + $canonicalLocalPath = Resolve-ValidatedOwnedProfilePath ` + ([string]$profile.LocalPath) $name + if (@($promoted | Where-Object { + Test-SamePath ([string]$_.LocalPath) $canonicalLocalPath + }).Count -ne 0) { + throw 'profile ownership promotion is ambiguous' + } + $promoted += [ordered]@{ + Sid = $sid + LocalPath = $canonicalLocalPath + Owned = $true + } + } + $Manifest.Profiles = @($Manifest.Profiles) + @($promoted) + return $true +} + +function Remove-OwnedProfiles($UserRecord, $ProfileRecords) { + if (!$UserRecord.Owned) { return } + $name = [string]$UserRecord.Name + if ($name -notmatch '^(?:propr-ci-|prpr)[a-f0-9]{8}$') { + throw 'owned local-user identity is invalid' + } + $sid = [string]$UserRecord.Sid + if ($sid -notmatch '^S-\d+(?:-\d+)+$') { + if ($UserRecord.Provisional -and + $null -eq (Get-LocalUser -Name $name -ErrorAction SilentlyContinue)) { return } + throw 'owned user SID was not durably resolved' + } + for ($attempt = 0; $attempt -lt 10; $attempt += 1) { + $profiles = @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop | Where-Object { + $_.SID -ceq $sid + }) + if ($profiles.Count -eq 0) { return } + try { + foreach ($profile in $profiles) { + if ([string]$profile.SID -cne $sid) { + throw 'profile lacks exact durable SID and path ownership' + } + $canonicalLocalPath = Resolve-ValidatedOwnedProfilePath ` + ([string]$profile.LocalPath) $name + $matchingRecords = @() + foreach ($record in @($ProfileRecords | Where-Object { + $_.Owned -and [string]$_.Sid -ceq $sid + })) { + $canonicalRecordPath = Resolve-ValidatedOwnedProfilePath ` + ([string]$record.LocalPath) $name + if (Test-SamePath $canonicalRecordPath $canonicalLocalPath) { + $matchingRecords += $record + } + } + if ($matchingRecords.Count -ne 1) { + throw 'profile lacks exact durable SID and path ownership' + } + # Re-resolve the live path and its one durable record at the deletion + # boundary so a changed root, ancestor, depth, leaf, SID, or path fails closed. + $canonicalLocalPath = Resolve-ValidatedOwnedProfilePath ` + ([string]$profile.LocalPath) $name + $canonicalRecordPath = Resolve-ValidatedOwnedProfilePath ` + ([string]$matchingRecords[0].LocalPath) $name + if ([string]$profile.SID -cne $sid -or + !(Test-SamePath $canonicalRecordPath $canonicalLocalPath)) { + throw 'profile ownership changed immediately before deletion' + } + Remove-CimInstance -InputObject $profile -ErrorAction Stop + } + } catch { + if ($attempt -eq 9) { throw } + Start-Sleep -Milliseconds 500 + } + } + throw 'owned profile cleanup did not complete' +} + +function Remove-ExplicitOwnedProfile($Record, $UserRecord) { + if (!$Record.Owned) { return } + $sid = [string]$Record.Sid + $localPath = [string]$Record.LocalPath + $name = [string]$UserRecord.Name + if (!$UserRecord.Owned -or [string]$UserRecord.Sid -cne $sid -or + $sid -notmatch '^S-\d+(?:-\d+)+$' -or ![IO.Path]::IsPathRooted($localPath)) { + throw 'profile cleanup identity is invalid' + } + $profiles = @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop | Where-Object { + $_.SID -ceq $sid + }) + foreach ($profile in $profiles) { + $canonicalRecordPath = Resolve-ValidatedOwnedProfilePath $localPath $name + $canonicalCurrentPath = Resolve-ValidatedOwnedProfilePath ` + ([string]$profile.LocalPath) $name + if ($profile.SID -cne $sid -or + !(Test-SamePath $canonicalCurrentPath $canonicalRecordPath)) { + throw 'profile path ownership changed' + } + $canonicalRecordPath = Resolve-ValidatedOwnedProfilePath $localPath $name + $canonicalCurrentPath = Resolve-ValidatedOwnedProfilePath ` + ([string]$profile.LocalPath) $name + if ($profile.SID -cne $sid -or + !(Test-SamePath $canonicalCurrentPath $canonicalRecordPath)) { + throw 'profile ownership changed immediately before deletion' + } + Remove-CimInstance -InputObject $profile -ErrorAction Stop + } +} + +function Remove-OwnedUser($Record) { + if (!$Record.Owned) { return } + $name = [string]$Record.Name + $sid = [string]$Record.Sid + if ($name -notmatch '^(?:propr-ci-|prpr)[a-f0-9]{8}$') { + throw 'owned local-user identity is invalid' + } + $user = Get-LocalUser -Name $name -ErrorAction SilentlyContinue + if ($null -eq $user) { return } + $ownershipMarker = [string]$Record.OwnershipMarker + if ($ownershipMarker -notmatch '^prpr-own-[a-f0-9]{32}$' -or + [string]$user.Description -cne $ownershipMarker) { + throw 'local-user ownership marker does not match' + } + if ($sid -notmatch '^S-\d+(?:-\d+)+$') { + throw 'owned local-user SID was not durably resolved' + } + if ($user.SID.Value -cne $sid) { throw 'local-user SID ownership changed' } + Remove-LocalUser -Name $name -ErrorAction Stop + if (Get-LocalUser -Name $name -ErrorAction SilentlyContinue) { + throw 'owned local-user cleanup did not complete' + } +} + +try { + $cleanupValidationPhase = 'FILE_AUTHORITY' + $manifestPath = [IO.Path]::GetFullPath($OwnershipManifest) + $tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + if ((Split-Path -Leaf $manifestPath) -notmatch + '^propr-installed-app-ownership-[a-f0-9]{32}\.json$' -or + !(Test-SamePath (Split-Path -Parent $manifestPath) $tempRoot)) { + throw 'ownership manifest path is invalid' + } + # Durable manifests are replaced atomically. Read from one authenticated + # ordinary-file handle while permitting that protocol's delete sharing, then + # prove the pathname still names the same entry before trusting the bytes. + $manifestStream = [IO.FileStream]::new( + $manifestPath, + [IO.FileMode]::Open, + [IO.FileAccess]::Read, + [IO.FileShare]'ReadWrite, Delete', + 4096, + [IO.FileOptions]::SequentialScan + ) + try { + if ($manifestStream.Length -le 0 -or $manifestStream.Length -gt 65536) { + throw 'ownership manifest metadata is invalid' + } + $manifestEntryIdentity = [ProPRDirectoryIdentity]::ReadHandle( + $manifestStream.SafeFileHandle, + $false + ) + $manifestBytes = [byte[]]::new([int]$manifestStream.Length) + $manifestOffset = 0 + while ($manifestOffset -lt $manifestBytes.Length) { + $read = $manifestStream.Read( + $manifestBytes, + $manifestOffset, + $manifestBytes.Length - $manifestOffset + ) + if ($read -eq 0) { throw 'ownership manifest read was incomplete' } + $manifestOffset += $read + } + if ($manifestStream.ReadByte() -ne -1) { throw 'ownership manifest changed during read' } + $manifestItem = Get-Item -LiteralPath $manifestPath -Force -ErrorAction Stop + if (($manifestItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + $manifestItem.Length -ne $manifestBytes.Length -or + [ProPRDirectoryIdentity]::ReadEntry($manifestPath, $false) -cne + $manifestEntryIdentity) { + throw 'ownership manifest entry changed during read' + } + } finally { + $manifestStream.Dispose() + } + $cleanupValidationPhase = 'UTF8_DECODE' + $strictUtf8 = [Text.UTF8Encoding]::new($false, $true) + $manifestJson = $strictUtf8.GetString($manifestBytes) + + $cleanupValidationPhase = 'JSON_PARSE' + $manifest = ConvertFrom-Json -InputObject $manifestJson -ErrorAction Stop + + $cleanupValidationPhase = 'EXACT_KEY_SET' + $manifestKeys = @($manifest.PSObject.Properties | ForEach-Object { $_.Name }) + $expectedManifestKeys = @( + 'SchemaVersion','ManifestType','State','RunId','CreatedUtcTicks','ExpiresUtcTicks', + 'InstallerPath','InstallerEntryIdentity','InstallerSha256','InstallerProductCode','Fixture', + 'FixtureRoot','BaselineClean','InstallAttempted','MsiTransactionState', + 'Directories','Files','RegistryKeys', + 'RegistryValues','Users','Profiles' + ) + if ($manifestKeys.Count -ne $expectedManifestKeys.Count -or + @($expectedManifestKeys | Where-Object { + $manifestKeys -cnotcontains $_ + }).Count -ne 0) { + throw 'ownership manifest key set is invalid' + } + + $cleanupValidationPhase = 'BOOLEAN_TYPES' + # Windows PowerShell 5.1 can retain an incidental PSObject wrapper around a + # JSON primitive. Inspect the explicit base object while still rejecting + # strings, numbers, and every other truthy value. + if ($null -eq $manifest.Fixture -or + $manifest.Fixture.PSObject.BaseObject.GetType() -ne [bool] -or + $null -eq $manifest.BaselineClean -or + $manifest.BaselineClean.PSObject.BaseObject.GetType() -ne [bool] -or + $null -eq $manifest.InstallAttempted -or + $manifest.InstallAttempted.PSObject.BaseObject.GetType() -ne [bool]) { + throw 'ownership manifest Boolean types are invalid' + } + + $cleanupValidationPhase = 'TRANSACTION_ENUM' + if ([string]$manifest.MsiTransactionState -cnotin @( + 'NONE','PENDING','COMMITTED','ROLLED_BACK_CLEAN' + )) { + throw 'ownership manifest transaction enum is invalid' + } + + $cleanupValidationPhase = 'SCHEMA_TYPE_STATE' + if ( + $manifest.SchemaVersion -ne 3 -or + [string]$manifest.ManifestType -cne 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP' -or + [string]$manifest.State -cnotin @('ACTIVE','EMPTY')) { + throw 'ownership manifest schema version, type, or state is invalid' + } + + $cleanupValidationPhase = 'RUN_ID_FORMAT' + $runIdBaseObject = if ($null -eq $manifest.RunId) { + $null + } else { $manifest.RunId.PSObject.BaseObject } + if ($null -eq $runIdBaseObject -or + $runIdBaseObject.GetType() -ne [string] -or + [string]$runIdBaseObject -cnotmatch '^[a-f0-9]{32}$') { + throw 'ownership manifest run identifier format is invalid' + } + + $cleanupValidationPhase = 'INSTALLER_ENTRY_ID_FORMAT' + $installerEntryIdBaseObject = if ($null -eq $manifest.InstallerEntryIdentity) { + $null + } else { $manifest.InstallerEntryIdentity.PSObject.BaseObject } + if ($null -eq $installerEntryIdBaseObject -or + $installerEntryIdBaseObject.GetType() -ne [string] -or + [string]$installerEntryIdBaseObject -cnotmatch '^[a-f0-9]{24}$') { + throw 'ownership manifest installer entry identifier format is invalid' + } + + $cleanupValidationPhase = 'INSTALLER_SHA256_FORMAT' + $installerSha256BaseObject = if ($null -eq $manifest.InstallerSha256) { + $null + } else { $manifest.InstallerSha256.PSObject.BaseObject } + if ($null -eq $installerSha256BaseObject -or + $installerSha256BaseObject.GetType() -ne [string] -or + [string]$installerSha256BaseObject -cnotmatch '^[a-f0-9]{64}$') { + throw 'ownership manifest installer digest format is invalid' + } + + $cleanupValidationPhase = 'INSTALLER_PRODUCT_CODE_FORMAT' + $installerProductCodeBaseObject = if ($null -eq $manifest.InstallerProductCode) { + $null + } else { $manifest.InstallerProductCode.PSObject.BaseObject } + if ($null -eq $installerProductCodeBaseObject -or + $installerProductCodeBaseObject.GetType() -ne [string] -or + [string]$installerProductCodeBaseObject -cnotmatch + '^\{[A-F0-9]{8}(?:-[A-F0-9]{4}){3}-[A-F0-9]{12}\}$') { + throw 'ownership manifest installer product-code format is invalid' + } + # Keep the validated JSON wire strings, not host-specific PSObject display + # representations, for every downstream authority comparison and receipt. + $manifest.RunId = [string]$runIdBaseObject + $manifest.InstallerEntryIdentity = [string]$installerEntryIdBaseObject + $manifest.InstallerSha256 = [string]$installerSha256BaseObject + $manifest.InstallerProductCode = [string]$installerProductCodeBaseObject + if (!$manifest.Fixture -and ( + ([string]$manifest.MsiTransactionState -ceq 'NONE' -and + [bool]$manifest.InstallAttempted) -or + ([string]$manifest.MsiTransactionState -in @( + 'PENDING','COMMITTED','ROLLED_BACK_CLEAN' + ) -and (!([bool]$manifest.BaselineClean) -or + !([bool]$manifest.InstallAttempted))))) { + throw 'MSI transaction receipt state is inconsistent' + } + $cleanupValidationPhase = 'RUN_ID' + $authorizedRunId = [string]$manifest.RunId + $pathRunId = [IO.Path]::GetFileNameWithoutExtension($manifestPath).Substring( + 'propr-installed-app-ownership-'.Length) + if ($authorizedRunId -cne $pathRunId -or $authorizedRunId -cne $ExpectedRunId) { + throw 'ownership manifest run identity is invalid' + } + $cleanupValidationPhase = 'LIFETIME' + $createdUtcTicks = [int64]$manifest.CreatedUtcTicks + $expiresUtcTicks = [int64]$manifest.ExpiresUtcTicks + $nowUtcTicks = [DateTime]::UtcNow.Ticks + if ($createdUtcTicks -le 0 -or $expiresUtcTicks -le $createdUtcTicks -or + $expiresUtcTicks - $createdUtcTicks -gt ([TimeSpan]::TicksPerHour * 3) -or + $createdUtcTicks -gt $nowUtcTicks + ([TimeSpan]::TicksPerMinute * 5) -or + $expiresUtcTicks -lt $nowUtcTicks) { + throw 'ownership manifest lifetime is invalid' + } + $cleanupValidationPhase = 'INSTALLER_PATH' + $resolvedInstaller = (Resolve-Path -LiteralPath $Installer -ErrorAction Stop).Path + if (!(Test-SamePath ([string]$manifest.InstallerPath) $resolvedInstaller)) { + throw 'ownership manifest installer identity is invalid' + } + $cleanupValidationPhase = 'FIXTURE_SCOPE' + if ($FixtureRoot) { + $FixtureRoot = (Resolve-Path -LiteralPath $FixtureRoot -ErrorAction Stop).Path + if (!$manifest.Fixture -or !(Test-SamePath ([string]$manifest.FixtureRoot) $FixtureRoot)) { + throw 'ownership manifest fixture scope is invalid' + } + } elseif ($manifest.Fixture) { + throw 'fixture ownership manifest was not authorized' + } + + # A worker that is terminated before its first marker cannot promote any + # resource authority. Accept only the exact supervisor-created fixture state: + # authenticated schema-v3 ACTIVE authority, no baseline or install attempt, + # transaction NONE, and no resource records. Revalidate the durable installer + # authority before atomically converting it to the ordinary EMPTY receipt. + $cleanupValidationPhase = 'INITIAL_ACTIVE_MATCH' + $initialActiveFixtureManifest = $manifest.Fixture -and + [string]$manifest.State -ceq 'ACTIVE' -and + !$manifest.BaselineClean -and !$manifest.InstallAttempted -and + [string]$manifest.MsiTransactionState -ceq 'NONE' -and + @($manifest.Directories).Count -eq 0 -and @($manifest.Files).Count -eq 0 -and + @($manifest.RegistryKeys).Count -eq 0 -and + @($manifest.RegistryValues).Count -eq 0 -and @($manifest.Users).Count -eq 0 -and + @($manifest.Profiles).Count -eq 0 + if ($FixtureValidationDiagnostic -and !$initialActiveFixtureManifest) { + throw 'initial fixture ownership authority does not match' + } + if ($initialActiveFixtureManifest) { + $cleanupValidationPhase = 'INITIAL_INSTALLER_AUTHORITY_RECHECK' + Assert-InstallerArtifactAuthority $manifest + $manifestValidated = $true + $cleanupValidationPhase = 'EMPTY_RECEIPT_WRITE' + Write-EmptyOwnershipReceipt $manifestPath $manifest + exit 0 + } + + if ([string]$manifest.State -ceq 'EMPTY') { + if ($manifest.BaselineClean -or $manifest.InstallAttempted -or + [string]$manifest.MsiTransactionState -cne 'NONE' -or + @($manifest.Directories).Count -ne 0 -or @($manifest.Files).Count -ne 0 -or + @($manifest.RegistryKeys).Count -ne 0 -or @($manifest.RegistryValues).Count -ne 0 -or + @($manifest.Users).Count -ne 0 -or @($manifest.Profiles).Count -ne 0) { + throw 'empty ownership receipt is invalid' + } + $manifestValidated = $true + exit 0 + } + + foreach ($record in @($manifest.Directories)) { + if ($record.Owned -and + !(Test-AllowedFileSystemPath ([string]$record.Kind) ([string]$record.Path))) { + throw 'directory manifest scope is invalid' + } + if ($record.Owned -and [string]$record.Kind -ceq 'SMOKE_DATA') { + [void](Resolve-SmokeDirectoryAuthority $record $manifest $manifestPath) + } + } + foreach ($record in @($manifest.Files)) { + if ($record.Owned -and + !(Test-AllowedFileSystemPath ([string]$record.Kind) ([string]$record.Path))) { + throw 'file manifest scope is invalid' + } + if ($record.Owned -and !$record.Provisional -and + ([string]$record.Identity -notmatch '^[a-f0-9]{64}$' -or + [string]$record.EntryIdentity -notmatch '^[a-f0-9]{24}$')) { + throw 'file manifest durable identity is invalid' + } + } + foreach ($record in @($manifest.Users)) { + if ($record.Owned -and ($record.Owned -isnot [bool] -or + $record.Provisional -isnot [bool])) { + throw 'user manifest ownership state is invalid' + } + if ($record.Owned -and [string]$record.Name -notmatch '^(?:propr-ci-|prpr)[a-f0-9]{8}$') { + throw 'user manifest identity is invalid' + } + if ($record.Owned -and !$record.Provisional -and + [string]$record.Sid -notmatch '^S-\d+(?:-\d+)+$') { + throw 'user manifest SID is invalid' + } + if ($record.Owned -and + [string]$record.OwnershipMarker -notmatch + '^prpr-own-[a-f0-9]{32}$') { + throw 'user manifest ownership marker is invalid' + } + } + foreach ($record in @($manifest.Profiles)) { + if ($record.Owned -and ([string]$record.Sid -notmatch '^S-\d+(?:-\d+)+$' -or + ![IO.Path]::IsPathRooted([string]$record.LocalPath))) { + throw 'profile manifest identity is invalid' + } + } + + $allowAuthenticatedMsiUninstall = !$manifest.Fixture -and + [bool]$manifest.BaselineClean -and [bool]$manifest.InstallAttempted -and + [string]$manifest.MsiTransactionState -ceq 'COMMITTED' + foreach ($record in @($manifest.RegistryKeys)) { + if (!$record.Owned) { continue } + $path = [string]$record.Path + $kind = [string]$record.Kind + if ($FixtureRoot) { + $expectedPath = "Registry::HKEY_LOCAL_MACHINE\Software\ProPRSupervisorFixture\$authorizedRunId\owned" + if (![string]::Equals($path, $expectedPath, [StringComparison]::OrdinalIgnoreCase)) { + throw 'registry manifest scope is invalid' + } + if (!(Test-Path -LiteralPath $path)) { continue } + if ([string](Get-ItemPropertyValue -LiteralPath $path -Name $ownerRegistryValue ` + -ErrorAction Stop) -cne [string]$record.Token) { + throw 'registry manifest token is invalid' + } + } else { + $expectedPath = if ($kind -eq 'PROTOCOL') { + 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr' + } elseif ($kind -eq 'APP_PATH') { + 'Registry::HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\CurrentVersion\App Paths\propr-desktop.exe' + } else { $null } + if (!$expectedPath -or + ![string]::Equals($path, $expectedPath, [StringComparison]::OrdinalIgnoreCase)) { + throw 'registry manifest scope is invalid' + } + if (!(Test-Path -LiteralPath $path)) { continue } + if ([bool]$record.Provisional -or + [string]$record.Identity -notmatch '^[a-f0-9]{64}$' -or + (Get-RegistryTreeIdentity $path) -cne [string]$record.Identity) { + throw 'registry manifest ownership identity is invalid' + } + } + } + foreach ($record in @($manifest.RegistryValues)) { + $recordKeys = @($record.PSObject.Properties | ForEach-Object { $_.Name }) + $expectedRecordKeys = @( + 'Kind','Path','Name','Owned','Provisional','BaselineKeyExisted', + 'BaselineValueExisted','BaselineValueKind','BaselineValueData', + 'IdentityValueKind','IdentityValueData','KeyCreatedByRun' + ) + if ($recordKeys.Count -ne $expectedRecordKeys.Count -or + @($expectedRecordKeys | Where-Object { $recordKeys -cnotcontains $_ }).Count -ne 0 -or + $record.Owned -isnot [bool] -or $record.Provisional -isnot [bool] -or + $record.BaselineKeyExisted -isnot [bool] -or + $record.BaselineValueExisted -isnot [bool] -or + $record.KeyCreatedByRun -isnot [bool] -or + [string]$record.Kind -cne 'HKCU_INSTALLED' -or + ![string]::Equals( + [string]$record.Path, + 'Registry::HKEY_CURRENT_USER\Software\ProPR\Desktop', + [StringComparison]::OrdinalIgnoreCase + ) -or [string]$record.Name -cne 'installed' -or + ([bool]$record.KeyCreatedByRun -and [bool]$record.BaselineKeyExisted)) { + throw 'registry value manifest scope is invalid' + } + if ([bool]$record.BaselineValueExisted) { + if (![bool]$record.BaselineKeyExisted -or + [string]$record.BaselineValueKind -notin @( + 'DWord','QWord','String','ExpandString','MultiString','Binary','None' + ) -or [string]$record.BaselineValueData -notmatch '^[A-Za-z0-9+/]*={0,2}$') { + throw 'registry value baseline is invalid' + } + try { + $baselineBytes = [Convert]::FromBase64String([string]$record.BaselineValueData) + if (([string]$record.BaselineValueKind -ceq 'DWord' -and + $baselineBytes.Length -ne 4) -or + ([string]$record.BaselineValueKind -ceq 'QWord' -and + $baselineBytes.Length -ne 8)) { + throw 'invalid baseline width' + } + if ([string]$record.BaselineValueKind -in @('String','ExpandString')) { + [void]([Text.UTF8Encoding]::new($false, $true).GetString($baselineBytes)) + } elseif ([string]$record.BaselineValueKind -ceq 'MultiString') { + $multiStringJson = [Text.UTF8Encoding]::new($false, $true).GetString($baselineBytes) + $multiStringValue = ConvertFrom-Json -InputObject $multiStringJson ` + -NoEnumerate -ErrorAction Stop + if ($multiStringValue -isnot [array] -or + @($multiStringValue | Where-Object { $_ -isnot [string] }).Count -ne 0) { + throw 'invalid multi-string baseline' + } + } + } catch { + throw 'registry value baseline is invalid' + } + } elseif ($null -ne $record.BaselineValueKind -or + $null -ne $record.BaselineValueData) { + throw 'registry value empty baseline is invalid' + } + if ($record.Owned -and !$record.Provisional) { + if ([string]$record.IdentityValueKind -notin @( + 'DWord','QWord','String','ExpandString','MultiString','Binary','None' + ) -or [string]$record.IdentityValueData -notmatch '^[A-Za-z0-9+/]*={0,2}$') { + throw 'registry value ownership identity is invalid' + } + } elseif ($null -ne $record.IdentityValueKind -or $null -ne $record.IdentityValueData) { + throw 'provisional registry value identity is invalid' + } + } + if (@($manifest.RegistryValues).Count -gt 1 -or + (!$manifest.Fixture -and $manifest.InstallAttempted -and + @($manifest.RegistryValues).Count -ne 1) -or + ($manifest.Fixture -and @($manifest.RegistryValues).Count -ne 0)) { + throw 'registry value manifest cardinality is invalid' + } + if (!$manifest.Fixture -and + [string]$manifest.MsiTransactionState -ceq 'COMMITTED') { + $ownedDirectoryKinds = @($manifest.Directories | Where-Object { + $_.Owned -and [string]$_.Kind -in @('INSTALL_ROOT','SHORTCUT_FOLDER') + } | ForEach-Object { [string]$_.Kind }) + $ownedFileKinds = @($manifest.Files | Where-Object { + $_.Owned -and [string]$_.Kind -ceq 'SHORTCUT_FILE' + } | ForEach-Object { [string]$_.Kind }) + $ownedRegistryKinds = @($manifest.RegistryKeys | Where-Object { + $_.Owned -and [string]$_.Kind -in @('PROTOCOL','APP_PATH') + } | ForEach-Object { [string]$_.Kind }) + if ($ownedDirectoryKinds.Count -ne 2 -or + @($ownedDirectoryKinds | Where-Object { + $_ -notin @('INSTALL_ROOT','SHORTCUT_FOLDER') + }).Count -ne 0 -or + @($ownedDirectoryKinds | Select-Object -Unique).Count -ne 2 -or + $ownedFileKinds.Count -ne 1 -or $ownedFileKinds[0] -cne 'SHORTCUT_FILE' -or + $ownedRegistryKinds.Count -ne 2 -or + @($ownedRegistryKinds | Where-Object { + $_ -notin @('PROTOCOL','APP_PATH') + }).Count -ne 0 -or + @($ownedRegistryKinds | Select-Object -Unique).Count -ne 2 -or + @($manifest.Directories | Where-Object { $_.Owned -and $_.Provisional }).Count -ne 0 -or + @($manifest.Files | Where-Object { $_.Owned -and $_.Provisional }).Count -ne 0 -or + @($manifest.RegistryKeys | Where-Object { $_.Owned -and $_.Provisional }).Count -ne 0 -or + @($manifest.RegistryValues | Where-Object { + !$_.Owned -or $_.Provisional + }).Count -ne 0) { + throw 'committed MSI transaction receipt is incomplete or provisional' + } + } + $manifestValidated = $true + # ACTIVE authority is inseparable from the exact installer entry captured by + # the supervisor. A same-path replacement blocks every cleanup mutation, + # including fixture/manual fallbacks that do not otherwise need Windows Installer. + Assert-InstallerArtifactAuthority $manifest + if (!$manifest.Fixture) { + if ([string]$manifest.MsiTransactionState -ceq 'PENDING') { + throw 'MSI transaction has no durable cleanup authority receipt' + } + if ([string]$manifest.MsiTransactionState -ceq 'NONE' -and + [bool]$manifest.InstallAttempted) { + throw 'MSI install attempt has no transaction receipt' + } + if ([string]$manifest.MsiTransactionState -ceq 'ROLLED_BACK_CLEAN') { + Assert-MsiRolledBackCleanBaseline $manifest + } + } + $ownershipPromoted = $false + foreach ($record in @($manifest.Users)) { + if (Resolve-ProvisionalOwnedUser $record) { $ownershipPromoted = $true } + if (Promote-UncapturedOwnedProfiles $record $manifest) { + $ownershipPromoted = $true + } + } + if ($ownershipPromoted) { + Write-DurableOwnershipManifest $manifestPath $manifest + } + foreach ($record in @($manifest.RegistryValues)) { + if (!$record.Owned) { continue } + $current = Get-RegistryValueSnapshot ([string]$record.Path) ([string]$record.Name) + $matchesBaseline = [bool]$record.BaselineValueExisted -and $current.Exists -and + $current.Kind -ceq [string]$record.BaselineValueKind -and + $current.Data -ceq [string]$record.BaselineValueData + if (!$matchesBaseline -and $current.Exists -and + (([bool]$record.Provisional -and + !(Test-MsiInstalledValue ([string]$record.Path) ([string]$record.Name))) -or + (![bool]$record.Provisional -and !(Test-RegistryValueIdentity $record $current)))) { + $cleanupFailed = $true + } + } + if ([string]$manifest.MsiTransactionState -ceq 'COMMITTED') { + Assert-MsiManagedFileSystemAuthority $manifest + } + if ($allowAuthenticatedMsiUninstall -and !$cleanupFailed) { + $msiExitCode = 1618 + for ($attempt = 0; $attempt -lt 12 -and $msiExitCode -eq 1618; $attempt += 1) { + if ($attempt -ne 0) { Start-Sleep -Seconds 2 } + Assert-MsiManagedFileSystemAuthority $manifest + Assert-InstallerArtifactAuthority $manifest + $msi = Start-Process msiexec.exe -ArgumentList @( + '/x', [string]$manifest.InstallerProductCode, '/qn', '/norestart' + ) -PassThru -WindowStyle Hidden -ErrorAction Stop + try { + [void]$msi.WaitForExit() + $msiExitCode = $msi.ExitCode + } finally { + $msi.Dispose() + } + } + if ($msiExitCode -notin @(0, 1605, 1614, 1641, 3010)) { $cleanupFailed = $true } + } + + foreach ($record in @($manifest.Files)) { + try { Remove-OwnedFile $record } catch { $cleanupFailed = $true } + } + foreach ($record in @($manifest.RegistryKeys)) { + try { Remove-OwnedRegistryKey $record } catch { $cleanupFailed = $true } + } + foreach ($record in @($manifest.RegistryValues)) { + try { Restore-OwnedRegistryValue $record } catch { $cleanupFailed = $true } + } + $profileCleanupFailed = $false + foreach ($record in @($manifest.Profiles)) { + try { + $profileOwners = @($manifest.Users | Where-Object { + $_.Owned -and [string]$_.Sid -ceq [string]$record.Sid + }) + if ($record.Owned -and $profileOwners.Count -ne 1) { + throw 'profile durable owner identity is ambiguous' + } + if ($record.Owned) { Remove-ExplicitOwnedProfile $record $profileOwners[0] } + } catch { + $profileCleanupFailed = $true + $cleanupFailed = $true + } + } + foreach ($record in @($manifest.Users)) { + try { Remove-OwnedProfiles $record $manifest.Profiles } catch { + $profileCleanupFailed = $true + $cleanupFailed = $true + } + } + if (!$profileCleanupFailed) { + foreach ($record in @($manifest.Users)) { + try { Remove-OwnedUser $record } catch { $cleanupFailed = $true } + } + } + $directories = @($manifest.Directories) | Sort-Object { + ([string]$_.Path).Length + } -Descending + foreach ($record in $directories) { + try { Remove-OwnedDirectory $record } catch { + $cleanupFailed = $true + } + } + if (!$cleanupFailed) { Write-EmptyOwnershipReceipt $manifestPath $manifest } +} catch { + $cleanupFailed = $true +} + +if ($cleanupFailed) { + Write-FixtureCleanupValidationPhase $cleanupValidationPhase + if ($manifestValidated) { exit 21 } + exit 20 +} +exit 0 diff --git a/apps/desktop/scripts/packaged-smoke-support.test.mjs b/apps/desktop/scripts/packaged-smoke-support.test.mjs index 0efd0b7a9..5e7fba41d 100644 --- a/apps/desktop/scripts/packaged-smoke-support.test.mjs +++ b/apps/desktop/scripts/packaged-smoke-support.test.mjs @@ -352,6 +352,25 @@ describe('packaged smoke child environment', () => { assert.doesNotMatch(smokeSource, /env:\s*\{[\s\S]*process\.env/); }); + test('serves each named fixture identity paired with its persisted credential', async () => { + const smokeSource = await readFile(new URL('./smoke-packaged.mjs', import.meta.url), 'utf8'); + const mainSource = await readFile(new URL('../src/main.ts', import.meta.url), 'utf8'); + assert.match( + smokeSource, + /name === 'first'\s*\? 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'\s*: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'/u, + ); + assert.match(smokeSource, /first = await listenFixture\('first'\);/u); + assert.match(smokeSource, /second = await listenFixture\('second'\);/u); + assert.match( + mainSource, + /origin: smoke\.firstOrigin,\s*publicInstanceIdentity: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'/u, + ); + assert.match( + mainSource, + /origin: smoke\.secondOrigin,\s*publicInstanceIdentity: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'/u, + ); + }); + test('requires the adjacent packaged spawn options with LF or CRLF source', () => { const options = [ ' cwd: smokeProfile.root,', diff --git a/apps/desktop/scripts/run-installed-windows-app-harness.ps1 b/apps/desktop/scripts/run-installed-windows-app-harness.ps1 new file mode 100644 index 000000000..5d623555e --- /dev/null +++ b/apps/desktop/scripts/run-installed-windows-app-harness.ps1 @@ -0,0 +1,1276 @@ +param( + [Parameter(Mandatory=$true)][string]$Installer, + [Parameter(Mandatory=$true)][ValidateSet('x64','arm64')][string]$Architecture, + [string]$WorkerPath, + [ValidateRange(1,60000)][int]$BootstrapTimeoutMilliseconds = 60 * 1000, + [ValidateRange(1,10000)][int]$WatchdogPollMilliseconds = 250, + [ValidateRange(1,30000)][int]$WatchdogTerminationMilliseconds = 30 * 1000, + [ValidateRange(1000,600000)][int]$PostTerminationCleanupMilliseconds = 4 * 60 * 1000, + [ValidateRange(1,5000)][int]$MarkerReadTimeoutMilliseconds = 250, + [string]$CancellationEventName, + [string]$FixtureCleanupRoot, + [string]$OwnershipManifest, + [string]$ExpectedRunId, + [switch]$InjectTerminationFailure +) + +$ErrorActionPreference = 'Stop' +$maximumMarkerDeadlineMilliseconds = 11 * 60 * 1000 +$msiCriticalTransactionGraceMilliseconds = 30 * 1000 +$watchdogStages = @( + 'INITIALIZATION','INSTALL','VALIDATION','USER_SETUP','APP_LAUNCH','APP_EXIT','UNINSTALL','CLEANUP' +) +$watchdogSubstages = @( + 'PATHS', + 'BASELINE', + 'MSI_INSTALL', + 'OWNERSHIP_CAPTURE', + 'INSTALL_TREE_SCAN', + 'APPLICATION_IMAGE', + 'PROTOCOL_ASSERTION', + 'APP_PATH_ASSERTION', + 'HKCU_INSTALLED_ASSERTION', + 'SHORTCUT_ASSERTION', + 'USER_CREATE', + 'USER_SID', + 'SMOKE_DATA_CREATE', + 'SHORTCUT_PRESENT_PROBE', + 'ALTERNATE_USER_START', + 'APPLICATION_WAIT', + 'STREAM_DRAIN', + 'EVIDENCE_INSPECTION', + 'MSI_UNINSTALL', + 'INSTALL_TREE_ASSERTION', + 'PROTOCOL_ABSENCE_ASSERTION', + 'APP_PATH_ABSENCE_ASSERTION', + 'HKCU_INSTALLED_ABSENCE_ASSERTION', + 'SHORTCUT_FILE_ASSERTION', + 'SHORTCUT_FOLDER_ASSERTION', + 'SHORTCUT_ABSENCE_PROBE', + 'SMOKE_DATA_REMOVE', + 'PROFILE_LOOKUP', + 'PROFILE_REMOVE', + 'USER_LOOKUP', + 'USER_REMOVE', + 'INSTALL_ROOT_FALLBACK', + 'PROTOCOL_FALLBACK', + 'APP_PATH_FALLBACK', + 'HKCU_INSTALLED_FALLBACK', + 'SHORTCUT_FALLBACK' +) +$markerName = "propr-installed-app-watchdog-$([Guid]::NewGuid().ToString('N')).marker" +$markerPath = Join-Path ([IO.Path]::GetTempPath()) $markerName +$generatedRunId = [Guid]::NewGuid().ToString('N') +$ownershipManifestName = "propr-installed-app-ownership-$generatedRunId.json" +$ownershipManifestPath = Join-Path ([IO.Path]::GetTempPath()) $ownershipManifestName +$workflowManagedManifest = $false +$ownershipReadyEventName = "Local\ProPRInstalledApp-$([Guid]::NewGuid().ToString('N'))" +$productionWorkerPath = Join-Path $PSScriptRoot 'test-installed-windows-app.ps1' +$cleanupWorkerPath = Join-Path $PSScriptRoot 'cleanup-installed-windows-app.ps1' +$worker = $null +$job = $null +$ownershipReadyEvent = $null +$cancellationEvent = $null +$lastValidMarker = $null +$exitCode = 125 +$terminateOwnedTree = $false +$workerStarted = $false +$supervisorOutcomeComplete = $false +$postTerminationCleanupAuthorized = $true +$fixtureNoMarkerDiagnostic = $false +$fixtureWindowsPowerShellCleanup = $false +$fixtureWorkerTreeTerminationOutcome = 'FAILED' +$fixtureCleanupChildExitCategory = 'OTHER' + +Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Win32.SafeHandles; + +public sealed class ProPRKillOnCloseJob : IDisposable +{ + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_BASIC_LIMIT_INFORMATION + { + public long PerProcessUserTimeLimit; + public long PerJobUserTimeLimit; + public uint LimitFlags; + public UIntPtr MinimumWorkingSetSize; + public UIntPtr MaximumWorkingSetSize; + public uint ActiveProcessLimit; + public UIntPtr Affinity; + public uint PriorityClass; + public uint SchedulingClass; + } + + [StructLayout(LayoutKind.Sequential)] + private struct IO_COUNTERS + { + public ulong ReadOperationCount; + public ulong WriteOperationCount; + public ulong OtherOperationCount; + public ulong ReadTransferCount; + public ulong WriteTransferCount; + public ulong OtherTransferCount; + } + + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION + { + public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation; + public IO_COUNTERS IoInfo; + public UIntPtr ProcessMemoryLimit; + public UIntPtr JobMemoryLimit; + public UIntPtr PeakProcessMemoryUsed; + public UIntPtr PeakJobMemoryUsed; + } + + private const int JobObjectExtendedLimitInformation = 9; + private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000; + private SafeFileHandle handle; + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateJobObject(IntPtr attributes, string name); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool SetInformationJobObject( + SafeFileHandle job, + int informationClass, + IntPtr information, + uint informationLength); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool AssignProcessToJobObject(SafeFileHandle job, IntPtr process); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool TerminateJobObject(SafeFileHandle job, uint exitCode); + + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_BASIC_ACCOUNTING_INFORMATION + { + public long TotalUserTime; + public long TotalKernelTime; + public long ThisPeriodTotalUserTime; + public long ThisPeriodTotalKernelTime; + public uint TotalPageFaultCount; + public uint TotalProcesses; + public uint ActiveProcesses; + public uint TotalTerminatedProcesses; + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool QueryInformationJobObject( + SafeFileHandle job, + int informationClass, + out JOBOBJECT_BASIC_ACCOUNTING_INFORMATION information, + uint informationLength, + IntPtr returnLength); + + public ProPRKillOnCloseJob() + { + handle = CreateJobObject(IntPtr.Zero, null); + if (handle == null || handle.IsInvalid) + throw new Win32Exception(Marshal.GetLastWin32Error(), "job creation failed"); + + var limits = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION(); + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + int size = Marshal.SizeOf(typeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION)); + IntPtr buffer = Marshal.AllocHGlobal(size); + try + { + Marshal.StructureToPtr(limits, buffer, false); + if (!SetInformationJobObject(handle, JobObjectExtendedLimitInformation, buffer, (uint)size)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "job configuration failed"); + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + + public void AddProcess(IntPtr processHandle) + { + if (!AssignProcessToJobObject(handle, processHandle)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "worker ownership failed"); + } + + private uint ReadActiveProcessCount() + { + JOBOBJECT_BASIC_ACCOUNTING_INFORMATION information; + uint size = (uint)Marshal.SizeOf(typeof(JOBOBJECT_BASIC_ACCOUNTING_INFORMATION)); + if (!QueryInformationJobObject(handle, 1, out information, size, IntPtr.Zero)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "job accounting failed"); + return information.ActiveProcesses; + } + + public bool TerminateAndWait(uint exitCode, int timeoutMilliseconds) + { + if (handle == null || handle.IsInvalid) + throw new InvalidOperationException("job handle is unavailable"); + if (!TerminateJobObject(handle, exitCode)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "job termination failed"); + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + do + { + if (ReadActiveProcessCount() == 0) return true; + System.Threading.Thread.Sleep(25); + } + while (stopwatch.ElapsedMilliseconds < timeoutMilliseconds); + return ReadActiveProcessCount() == 0; + } + + public void Dispose() + { + if (handle != null) handle.Dispose(); + } +} + +public static class ProPRInstallerEntryIdentity +{ + [StructLayout(LayoutKind.Sequential)] + private struct BY_HANDLE_FILE_INFORMATION + { + public uint FileAttributes; + public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime; + public uint VolumeSerialNumber; + public uint FileSizeHigh; + public uint FileSizeLow; + public uint NumberOfLinks; + public uint FileIndexHigh; + public uint FileIndexLow; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFile( + string path, uint access, uint share, IntPtr security, uint creation, + uint flags, IntPtr template); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetFileInformationByHandle( + SafeFileHandle handle, out BY_HANDLE_FILE_INFORMATION information); + + public static string Read(string path) + { + using (SafeFileHandle handle = CreateFile( + path, 0x80, 0x7, IntPtr.Zero, 3, 0x00200000, IntPtr.Zero)) + { + if (handle == null || handle.IsInvalid) + throw new Win32Exception(Marshal.GetLastWin32Error(), "installer identity open failed"); + BY_HANDLE_FILE_INFORMATION information; + if (!GetFileInformationByHandle(handle, out information)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "installer identity read failed"); + if ((information.FileAttributes & (0x10 | 0x400)) != 0) + throw new InvalidOperationException("installer entry is not an ordinary file"); + return string.Format("{0:x8}{1:x8}{2:x8}", information.VolumeSerialNumber, + information.FileIndexHigh, information.FileIndexLow); + } + } +} + +public enum ProPRMarkerReadState +{ + Missing, + Valid, + Invalid, + Inaccessible +} + +public sealed class ProPRMarkerReadResult +{ + public ProPRMarkerReadState State; + public long Deadline; + public string Stage; + public string Substage; + public string Status; +} + +public static class ProPRBoundedMarkerReader +{ + private const int MaximumMarkerBytes = 256; + private static readonly Regex MarkerPattern = new Regex( + "^(?[0-9]+)\\|(?[A-Z_]+)\\|(?[A-Z_]+)\\|(?BEGIN|COMPLETE|FAILED)$", + RegexOptions.CultureInvariant | RegexOptions.Compiled); + + public static Task ReadAsync(string path) + { + return Task.Run(() => Read(path)); + } + + private static ProPRMarkerReadResult Result(ProPRMarkerReadState state) + { + return new ProPRMarkerReadResult { State = state }; + } + + private static ProPRMarkerReadResult Read(string path) + { + try + { + var item = new FileInfo(path); + item.Refresh(); + if (!item.Exists) return Result(ProPRMarkerReadState.Missing); + if ((item.Attributes & FileAttributes.ReparsePoint) != 0 || item.Length <= 0 || + item.Length > MaximumMarkerBytes) + return Result(ProPRMarkerReadState.Invalid); + + int length = checked((int)item.Length); + var bytes = new byte[length]; + using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete, 256, FileOptions.SequentialScan)) + { + int offset = 0; + while (offset < length) + { + int read = stream.Read(bytes, offset, length - offset); + if (read == 0) return Result(ProPRMarkerReadState.Invalid); + offset += read; + } + if (stream.ReadByte() != -1) return Result(ProPRMarkerReadState.Invalid); + } + + for (int index = 0; index < bytes.Length; index++) + if (bytes[index] > 0x7f) return Result(ProPRMarkerReadState.Invalid); + string text = Encoding.ASCII.GetString(bytes); + Match match = MarkerPattern.Match(text); + long deadline; + if (!match.Success || !long.TryParse(match.Groups["Deadline"].Value, + NumberStyles.None, CultureInfo.InvariantCulture, out deadline)) + return Result(ProPRMarkerReadState.Invalid); + return new ProPRMarkerReadResult { + State = ProPRMarkerReadState.Valid, + Deadline = deadline, + Stage = match.Groups["Stage"].Value, + Substage = match.Groups["Substage"].Value, + Status = match.Groups["Status"].Value + }; + } + catch (FileNotFoundException) { return Result(ProPRMarkerReadState.Missing); } + catch (DirectoryNotFoundException) { return Result(ProPRMarkerReadState.Missing); } + catch (UnauthorizedAccessException) { return Result(ProPRMarkerReadState.Inaccessible); } + catch (IOException) { return Result(ProPRMarkerReadState.Inaccessible); } + catch { return Result(ProPRMarkerReadState.Invalid); } + } +} + +public sealed class ProPRCleanupDiagnosticDrainResult +{ + public long StandardOutputBytes; + public long StandardOutputLines; + public byte[] StandardOutput; + public long StandardErrorBytes; + public long StandardErrorLines; +} + +public sealed class ProPRCleanupDiagnosticDrain : IDisposable +{ + public const int StandardOutputByteLimit = 96; + public const int StandardOutputLineLimit = 1; + public const int StandardErrorByteLimit = 0; + public const int StandardErrorLineLimit = 0; + + private sealed class PumpResult + { + public long Bytes; + public long Lines; + public byte[] Captured; + } + + private readonly CancellationTokenSource cancellation = new CancellationTokenSource(); + private Stream standardOutput; + private Stream standardError; + private Task standardOutputTask; + private Task standardErrorTask; + + private static async Task Pump( + Stream stream, + int byteLimit, + int lineLimit, + CancellationToken token) + { + var buffer = new byte[64]; + using (var captured = new MemoryStream(byteLimit + 1)) + { + long bytes = 0; + long lines = 0; + while (true) + { + int count = await stream.ReadAsync( + buffer, 0, buffer.Length, token).ConfigureAwait(false); + if (count == 0) + { + return new PumpResult { + Bytes = bytes, + Lines = lines, + Captured = captured.ToArray() + }; + } + bytes = Math.Min((long)byteLimit + 1, bytes + count); + for (int index = 0; index < count; index++) + if (buffer[index] == (byte)'\n') + lines = Math.Min((long)lineLimit + 1, lines + 1); + int remaining = byteLimit + 1 - checked((int)captured.Length); + if (remaining > 0) + captured.Write(buffer, 0, Math.Min(remaining, count)); + } + } + } + + public void Start(Process process) + { + if (standardOutputTask != null || standardErrorTask != null) + throw new InvalidOperationException("diagnostic drain was already started"); + standardOutput = process.StandardOutput.BaseStream; + standardError = process.StandardError.BaseStream; + standardOutputTask = Pump( + standardOutput, + StandardOutputByteLimit, + StandardOutputLineLimit, + cancellation.Token); + standardErrorTask = Pump( + standardError, + StandardErrorByteLimit, + StandardErrorLineLimit, + cancellation.Token); + } + + public ProPRCleanupDiagnosticDrainResult Finish(int timeoutMilliseconds) + { + if (standardOutputTask == null || standardErrorTask == null) + throw new InvalidOperationException("diagnostic drain was not started"); + Task all = Task.WhenAll(standardOutputTask, standardErrorTask); + if (!all.Wait(timeoutMilliseconds)) return null; + if (standardOutputTask.IsFaulted || standardOutputTask.IsCanceled || + standardErrorTask.IsFaulted || standardErrorTask.IsCanceled) + throw new InvalidOperationException("diagnostic drain failed"); + PumpResult output = standardOutputTask.Result; + PumpResult error = standardErrorTask.Result; + return new ProPRCleanupDiagnosticDrainResult { + StandardOutputBytes = output.Bytes, + StandardOutputLines = output.Lines, + StandardOutput = output.Captured, + StandardErrorBytes = error.Bytes, + StandardErrorLines = error.Lines + }; + } + + public bool CancelAndFinish(int timeoutMilliseconds) + { + cancellation.Cancel(); + try { if (standardOutput != null) standardOutput.Dispose(); } catch { } + try { if (standardError != null) standardError.Dispose(); } catch { } + if (standardOutputTask == null || standardErrorTask == null) return true; + try { Task.WhenAll(standardOutputTask, standardErrorTask).Wait(timeoutMilliseconds); } + catch { } + return standardOutputTask.IsCompleted && standardErrorTask.IsCompleted; + } + + public void Dispose() + { + CancelAndFinish(1000); + cancellation.Dispose(); + } +} +'@ + +function Get-InstallerSha256([string]$Path) { + $stream = [IO.File]::Open( + $Path, + [IO.FileMode]::Open, + [IO.FileAccess]::Read, + [IO.FileShare]::Read + ) + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + return [BitConverter]::ToString($sha256.ComputeHash($stream)).Replace('-', '').ToLowerInvariant() + } finally { + $sha256.Dispose() + $stream.Dispose() + } +} + +function Get-MsiProductCode([string]$Path) { + $installerCom = $null + $database = $null + $view = $null + $record = $null + try { + $installerCom = New-Object -ComObject WindowsInstaller.Installer + $database = $installerCom.OpenDatabase($Path, 0) + $view = $database.OpenView( + "SELECT ``Value`` FROM ``Property`` WHERE ``Property`` = 'ProductCode'") + $view.Execute() + $record = $view.Fetch() + $productCode = if ($null -eq $record) { $null } else { [string]$record.StringData(1) } + if ($productCode -notmatch '^\{[A-Fa-f0-9]{8}(?:-[A-Fa-f0-9]{4}){3}-[A-Fa-f0-9]{12}\}$') { + throw 'MSI product identity is invalid' + } + return $productCode.ToUpperInvariant() + } finally { + foreach ($resource in @($record, $view, $database, $installerCom)) { + if ($null -ne $resource -and [Runtime.InteropServices.Marshal]::IsComObject($resource)) { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($resource) + } + } + } +} + +function Get-InstallerAuthority([string]$Path) { + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if ($item.PSIsContainer -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'installer artifact is not an ordinary file' + } + $canonicalPath = (Resolve-Path -LiteralPath $item.FullName -ErrorAction Stop).ProviderPath + $entryIdentity = [ProPRInstallerEntryIdentity]::Read($canonicalPath) + $sha256 = Get-InstallerSha256 $canonicalPath + if ([ProPRInstallerEntryIdentity]::Read($canonicalPath) -cne $entryIdentity -or + (Get-InstallerSha256 $canonicalPath) -cne $sha256) { + throw 'installer artifact changed before product identity capture' + } + $productCode = Get-MsiProductCode $canonicalPath + if ([ProPRInstallerEntryIdentity]::Read($canonicalPath) -cne $entryIdentity -or + (Get-InstallerSha256 $canonicalPath) -cne $sha256) { + throw 'installer artifact changed during authority capture' + } + return [PSCustomObject]@{ + Path = $canonicalPath + EntryIdentity = $entryIdentity + Sha256 = $sha256 + ProductCode = $productCode + } +} + +function Test-InstallerArtifactAuthority($Record) { + try { + return [string]$Record.InstallerEntryIdentity -match '^[a-f0-9]{24}$' -and + [string]$Record.InstallerSha256 -match '^[a-f0-9]{64}$' -and + [string]$Record.InstallerProductCode -match + '^\{[A-F0-9]{8}(?:-[A-F0-9]{4}){3}-[A-F0-9]{12}\}$' -and + [ProPRInstallerEntryIdentity]::Read([string]$Record.InstallerPath) -ceq + [string]$Record.InstallerEntryIdentity -and + (Get-InstallerSha256 ([string]$Record.InstallerPath)) -ceq + [string]$Record.InstallerSha256 + } catch { + return $false + } +} + +function Write-WatchdogLine([string]$Line) { + Write-Host $Line + [Console]::Out.Flush() +} + +function Read-WatchdogMarker([string]$Path, [int]$TimeoutMilliseconds) { + $readTask = [ProPRBoundedMarkerReader]::ReadAsync($Path) + if (!$readTask.Wait($TimeoutMilliseconds)) { + return [PSCustomObject]@{ State = 'TimedOut' } + } + $result = $readTask.Result + if ($result.State -ne [ProPRMarkerReadState]::Valid) { + return [PSCustomObject]@{ State = $result.State.ToString() } + } + return [PSCustomObject]@{ + State = 'Valid' + Deadline = $result.Deadline + Stage = $result.Stage + Substage = $result.Substage + Status = $result.Status + } +} + +function Test-FreshMarker($Marker) { + $now = [DateTime]::UtcNow.Ticks + if ($Marker.Deadline -le $now) { return $false } + return ($Marker.Deadline - $now) -le + ([int64]$maximumMarkerDeadlineMilliseconds * [TimeSpan]::TicksPerMillisecond) +} + +function Test-WatchdogMarkerSchema($Marker) { + return $watchdogStages -ccontains $Marker.Stage -and + $watchdogSubstages -ccontains $Marker.Substage +} + +function Accept-WatchdogMarker($Marker) { + $identity = '{0}:{1}:{2}:{3}' -f $Marker.Deadline, $Marker.Stage, $Marker.Substage, $Marker.Status + $previousIdentity = if ($null -eq $script:lastValidMarker) { $null } else { + '{0}:{1}:{2}:{3}' -f $script:lastValidMarker.Deadline, $script:lastValidMarker.Stage, + $script:lastValidMarker.Substage, $script:lastValidMarker.Status + } + $script:lastValidMarker = $Marker + if ($identity -cne $previousIdentity) { + Write-WatchdogLine ('PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:ACCEPTED:{0}:{1}:{2}' -f ` + $Marker.Stage, $Marker.Substage, $Marker.Status) + } +} + +function Stop-OwnedWorker([uint32]$TerminationExitCode) { + if ($null -eq $job) { return $false } + if ($InjectTerminationFailure) { + try { + $job.Dispose() + $script:job = $null + if ($null -ne $worker) { + [void]$worker.WaitForExit($WatchdogTerminationMilliseconds) + } + } catch {} + return $false + } + try { + if (!$job.TerminateAndWait($TerminationExitCode, $WatchdogTerminationMilliseconds)) { + return $false + } + $job.Dispose() + $script:job = $null + if ($null -eq $worker) { return !$workerStarted } + if (!$worker.WaitForExit($WatchdogTerminationMilliseconds) -or !$worker.HasExited) { + return $false + } + return $true + } catch { + try { + if ($null -ne $job) { + $job.Dispose() + $script:job = $null + } + if ($null -ne $worker) { + [void]$worker.WaitForExit($WatchdogTerminationMilliseconds) + } + } catch {} + return $false + } +} + +function Get-CanonicalManifestIdentifiers([string]$RunId, $InstallerAuthority) { + if ($RunId -cnotmatch '^[a-f0-9]{32}$') { + throw 'manifest run identifier is not canonical' + } + + $entryIdentity = [string]$InstallerAuthority.EntryIdentity + if ($entryIdentity -notmatch '^[A-Fa-f0-9]{24}$') { + throw 'installer entry identifier cannot be represented canonically' + } + $entryIdentity = $entryIdentity.ToLowerInvariant() + + $sha256 = [string]$InstallerAuthority.Sha256 + if ($sha256 -notmatch '^[A-Fa-f0-9]{64}$') { + throw 'installer digest cannot be represented canonically' + } + $sha256 = $sha256.ToLowerInvariant() + + $productCodeText = [string]$InstallerAuthority.ProductCode + $productCode = [Guid]::Empty + if (![Guid]::TryParseExact($productCodeText, 'B', [ref]$productCode)) { + throw 'installer product code cannot be represented canonically' + } + $productCodeText = $productCode.ToString('B').ToUpperInvariant() + + if ($entryIdentity -cnotmatch '^[a-f0-9]{24}$' -or + $sha256 -cnotmatch '^[a-f0-9]{64}$' -or + $productCodeText -cnotmatch + '^\{[A-F0-9]{8}(?:-[A-F0-9]{4}){3}-[A-F0-9]{12}\}$') { + throw 'canonical manifest identifier construction failed' + } + + return [PSCustomObject]@{ + RunId = $RunId + InstallerEntryIdentity = $entryIdentity + InstallerSha256 = $sha256 + InstallerProductCode = $productCodeText + } +} + +function Write-InitialOwnershipManifest( + [string]$Path, + $InstallerAuthority, + [bool]$Fixture, + [string]$AuthorizedFixtureRoot +) { + $runId = [IO.Path]::GetFileNameWithoutExtension($Path).Substring( + 'propr-installed-app-ownership-'.Length) + $identifiers = Get-CanonicalManifestIdentifiers $runId $InstallerAuthority + $createdUtcTicks = [DateTime]::UtcNow.Ticks + $manifest = [ordered]@{ + SchemaVersion = 3 + ManifestType = 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP' + State = 'ACTIVE' + RunId = $identifiers.RunId + CreatedUtcTicks = $createdUtcTicks + ExpiresUtcTicks = $createdUtcTicks + ([TimeSpan]::TicksPerHour * 3) + InstallerPath = [string]$InstallerAuthority.Path + InstallerEntryIdentity = $identifiers.InstallerEntryIdentity + InstallerSha256 = $identifiers.InstallerSha256 + InstallerProductCode = $identifiers.InstallerProductCode + Fixture = $Fixture + FixtureRoot = if ($Fixture) { $AuthorizedFixtureRoot } else { $null } + BaselineClean = $false + InstallAttempted = $false + MsiTransactionState = 'NONE' + Directories = @() + Files = @() + RegistryKeys = @() + RegistryValues = @() + Users = @() + Profiles = @() + } + $manifestJson = $manifest | ConvertTo-Json -Depth 6 -Compress + $roundTrip = ConvertFrom-Json -InputObject $manifestJson -ErrorAction Stop + if ([string]$roundTrip.RunId -cne $identifiers.RunId -or + [string]$roundTrip.InstallerEntryIdentity -cne + $identifiers.InstallerEntryIdentity -or + [string]$roundTrip.InstallerSha256 -cne $identifiers.InstallerSha256 -or + [string]$roundTrip.InstallerProductCode -cne + $identifiers.InstallerProductCode) { + throw 'canonical manifest identifier round trip failed' + } + $bytes = [Text.Encoding]::UTF8.GetBytes($manifestJson) + $stream = [IO.FileStream]::new( + $Path, + [IO.FileMode]::CreateNew, + [IO.FileAccess]::Write, + [IO.FileShare]::Read, + 4096, + [IO.FileOptions]::WriteThrough + ) + try { + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + } finally { + $stream.Dispose() + } +} + +function Test-MsiCriticalMarker($Marker) { + return $null -ne $Marker -and [string]$Marker.Stage -ceq 'INSTALL' -and + [string]$Marker.Substage -in @('MSI_INSTALL','OWNERSHIP_CAPTURE') -and + !([string]$Marker.Substage -ceq 'OWNERSHIP_CAPTURE' -and + [string]$Marker.Status -ceq 'COMPLETE') +} + +function Get-DurableMsiTransactionReceipt { + try { + $item = Get-Item -LiteralPath $ownershipManifestPath -Force -ErrorAction Stop + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + $item.Length -le 0 -or $item.Length -gt 65536) { return 'UNAVAILABLE' } + $bytes = [byte[]]::new([int]$item.Length) + $stream = [IO.FileStream]::new( + $item.FullName, + [IO.FileMode]::Open, + [IO.FileAccess]::Read, + [IO.FileShare]'ReadWrite, Delete', + 4096, + [IO.FileOptions]::SequentialScan + ) + try { + $offset = 0 + while ($offset -lt $bytes.Length) { + $read = $stream.Read($bytes, $offset, $bytes.Length - $offset) + if ($read -eq 0) { return 'UNAVAILABLE' } + $offset += $read + } + if ($stream.ReadByte() -ne -1) { return 'UNAVAILABLE' } + } finally { + $stream.Dispose() + } + $manifest = ConvertFrom-Json ` + -InputObject ([Text.UTF8Encoding]::new($false, $true).GetString($bytes)) ` + -ErrorAction Stop + $manifestKeys = @($manifest.PSObject.Properties | ForEach-Object { $_.Name }) + $expectedManifestKeys = @( + 'SchemaVersion','ManifestType','State','RunId','CreatedUtcTicks','ExpiresUtcTicks', + 'InstallerPath','InstallerEntryIdentity','InstallerSha256','InstallerProductCode', + 'Fixture','FixtureRoot','BaselineClean','InstallAttempted','MsiTransactionState', + 'Directories','Files','RegistryKeys','RegistryValues','Users','Profiles' + ) + if ($manifestKeys.Count -ne $expectedManifestKeys.Count -or + @($expectedManifestKeys | Where-Object { + $manifestKeys -cnotcontains $_ + }).Count -ne 0 -or + $manifest.SchemaVersion -ne 3 -or + [string]$manifest.RunId -cne $ownershipRunId -or + !(Test-InstallerArtifactAuthority $manifest) -or + [string]$manifest.State -notin @('ACTIVE','EMPTY')) { return 'UNAVAILABLE' } + if ([string]$manifest.State -ceq 'EMPTY' -and + [string]$manifest.MsiTransactionState -ceq 'NONE' -and + !$manifest.InstallAttempted) { return 'ROLLED_BACK_CLEAN' } + if ([string]$manifest.MsiTransactionState -ceq 'ROLLED_BACK_CLEAN' -and + @($manifest.Directories).Count -eq 0 -and @($manifest.Files).Count -eq 0 -and + @($manifest.RegistryKeys).Count -eq 0 -and + (($manifest.Fixture -and @($manifest.RegistryValues).Count -eq 0) -or + (!$manifest.Fixture -and @($manifest.RegistryValues).Count -eq 1 -and + !$manifest.RegistryValues[0].Owned))) { + return 'ROLLED_BACK_CLEAN' + } + if ([string]$manifest.MsiTransactionState -cne 'COMMITTED') { return 'UNAVAILABLE' } + $ownedDirectories = @($manifest.Directories | Where-Object { + $_.Owned -and [string]$_.Kind -in @('INSTALL_ROOT','SHORTCUT_FOLDER') -and + !$_.Provisional -and + [string]$_.Identity -match '^[a-f0-9]{24}$' -and + [string]$_.TreeIdentity -match '^[a-f0-9]{64}$' + }) + $ownedFiles = @($manifest.Files | Where-Object { + $_.Owned -and [string]$_.Kind -ceq 'SHORTCUT_FILE' -and !$_.Provisional -and + [string]$_.Identity -match '^[a-f0-9]{64}$' -and + [string]$_.EntryIdentity -match '^[a-f0-9]{24}$' + }) + $ownedRegistryKeys = @($manifest.RegistryKeys | Where-Object { + $_.Owned -and [string]$_.Kind -in @('PROTOCOL','APP_PATH') -and + !$_.Provisional -and [string]$_.Identity -match '^[a-f0-9]{64}$' + }) + $ownedRegistryValues = @($manifest.RegistryValues | Where-Object { + $_.Owned -and [string]$_.Kind -ceq 'HKCU_INSTALLED' -and !$_.Provisional -and + [string]$_.IdentityValueKind -and [string]$_.IdentityValueData + }) + if ($ownedDirectories.Count -ne 2 -or $ownedFiles.Count -ne 1 -or + (!$manifest.Fixture -and + ($ownedRegistryKeys.Count -ne 2 -or $ownedRegistryValues.Count -ne 1))) { + return 'UNAVAILABLE' + } + return 'COMMITTED' + } catch { + return 'UNAVAILABLE' + } +} + +function Wait-MsiCriticalTransactionReceipt { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:MSI_TRANSACTION:GRACE' + $stopwatch = [Diagnostics.Stopwatch]::StartNew() + do { + $receipt = Get-DurableMsiTransactionReceipt + if ($receipt -in @('COMMITTED','ROLLED_BACK_CLEAN')) { + Write-WatchdogLine ` + "PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:MSI_TRANSACTION:$receipt" + return $true + } + Start-Sleep -Milliseconds 25 + } while ($stopwatch.ElapsedMilliseconds -lt $msiCriticalTransactionGraceMilliseconds) + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:MSI_TRANSACTION:UNPROVEN' + return $false +} + +function Invoke-PostTerminationCleanup([string]$InstallerPath, [string]$AuthorizedFixtureRoot) { + $cleanupJob = $null + $cleanupProcess = $null + $cleanupReadyEvent = $null + $cleanupDiagnosticDrain = $null + try { + $cleanupReadyEventName = "Local\ProPRInstalledAppCleanup-$([Guid]::NewGuid().ToString('N'))" + $cleanupReadyEvent = [Threading.EventWaitHandle]::new( + $false, + [Threading.EventResetMode]::ManualReset, + $cleanupReadyEventName + ) + $cleanupStartInfo = [Diagnostics.ProcessStartInfo]::new() + # Production and the principal fixture use the exact host that launched the + # supervisor. A separate fixture retains Windows PowerShell 5.1 coverage + # without attributing native pwsh 7 evidence to that compatibility host. + $cleanupHostPath = $hostPath + if ($fixtureWindowsPowerShellCleanup) { + $cleanupHostPath = Join-Path $env:SystemRoot ` + 'System32\WindowsPowerShell\v1.0\powershell.exe' + if (!(Test-Path -LiteralPath $cleanupHostPath -PathType Leaf)) { + throw 'Windows PowerShell 5.1 fixture host is unavailable' + } + } + $cleanupStartInfo.FileName = $cleanupHostPath + $cleanupStartInfo.UseShellExecute = $false + $cleanupStartInfo.CreateNoWindow = $true + foreach ($argument in @( + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-File', $cleanupWorkerPath, + '-OwnershipManifest', $ownershipManifestPath, + '-Installer', $InstallerPath, + '-ExpectedRunId', $ownershipRunId, + '-OwnershipReadyEvent', $cleanupReadyEventName + )) { + $cleanupStartInfo.ArgumentList.Add($argument) + } + if ($AuthorizedFixtureRoot) { + $cleanupStartInfo.ArgumentList.Add('-FixtureRoot') + $cleanupStartInfo.ArgumentList.Add($AuthorizedFixtureRoot) + } + if ($fixtureNoMarkerDiagnostic) { + $cleanupStartInfo.ArgumentList.Add('-FixtureValidationDiagnostic') + $cleanupStartInfo.RedirectStandardOutput = $true + $cleanupStartInfo.RedirectStandardError = $true + } + + $cleanupJob = [ProPRKillOnCloseJob]::new() + if ($fixtureNoMarkerDiagnostic) { + $cleanupDiagnosticDrain = [ProPRCleanupDiagnosticDrain]::new() + } + $cleanupProcess = [Diagnostics.Process]::new() + $cleanupProcess.StartInfo = $cleanupStartInfo + if (!$cleanupProcess.Start()) { throw 'post-termination cleanup did not start' } + try { + $cleanupJob.AddProcess($cleanupProcess.Handle) + [void]$cleanupReadyEvent.Set() + if ($fixtureNoMarkerDiagnostic) { + $cleanupDiagnosticDrain.Start($cleanupProcess) + } + } catch { + try { $cleanupProcess.Kill($true) } catch {} + throw 'post-termination cleanup ownership failed' + } + if (!$cleanupProcess.WaitForExit($PostTerminationCleanupMilliseconds)) { + $cleanupTreeGone = $false + try { + $cleanupTreeGone = $cleanupJob.TerminateAndWait( + 125, + $WatchdogTerminationMilliseconds + ) -and $cleanupProcess.WaitForExit($WatchdogTerminationMilliseconds) -and + $cleanupProcess.HasExited + } catch {} + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:TIMED_OUT' + return $false + } + $script:fixtureCleanupChildExitCategory = if ($cleanupProcess.ExitCode -in @(0,20,21)) { + ([int]$cleanupProcess.ExitCode).ToString( + [Globalization.CultureInfo]::InvariantCulture) + } else { 'OTHER' } + if ($fixtureNoMarkerDiagnostic) { + # The fixture protocol permits exactly one bounded phase line for + # validation exit 20 or post-validation exit 21. Exit 0 is the explicitly + # defined zero-byte success protocol. Any other child output leaves + # recovery authority in place and fails closed. + $diagnosticDrainResult = $cleanupDiagnosticDrain.Finish( + $WatchdogTerminationMilliseconds) + if ($null -eq $diagnosticDrainResult -or + $diagnosticDrainResult.StandardErrorBytes -ne 0 -or + $diagnosticDrainResult.StandardErrorLines -ne 0 -or + $diagnosticDrainResult.StandardOutputBytes -gt + [ProPRCleanupDiagnosticDrain]::StandardOutputByteLimit -or + $diagnosticDrainResult.StandardOutputLines -gt + [ProPRCleanupDiagnosticDrain]::StandardOutputLineLimit) { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' + return $false + } + if ($cleanupProcess.ExitCode -eq 0) { + if ($diagnosticDrainResult.StandardOutputBytes -ne 0 -or + $diagnosticDrainResult.StandardOutputLines -ne 0) { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' + return $false + } + } elseif ($cleanupProcess.ExitCode -in @(20,21)) { + $diagnosticBytes = [byte[]]$diagnosticDrainResult.StandardOutput + if ($diagnosticDrainResult.StandardOutputLines -ne 1 -or + @($diagnosticBytes | Where-Object { $_ -gt 0x7f }).Count -ne 0) { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' + return $false + } + $diagnosticMatch = [regex]::Match( + [Text.Encoding]::ASCII.GetString($diagnosticBytes), + ('\ACLEANUP_VALIDATION_PHASE:' + + '(HANDSHAKE|FILE_AUTHORITY|UTF8_DECODE|JSON_PARSE|EXACT_KEY_SET|' + + 'BOOLEAN_TYPES|TRANSACTION_ENUM|SCHEMA_TYPE_STATE|' + + 'RUN_ID_FORMAT|INSTALLER_ENTRY_ID_FORMAT|INSTALLER_SHA256_FORMAT|' + + 'INSTALLER_PRODUCT_CODE_FORMAT|LIFETIME|RUN_ID|INSTALLER_PATH|FIXTURE_SCOPE|' + + 'INITIAL_ACTIVE_MATCH|INITIAL_INSTALLER_AUTHORITY_RECHECK|' + + 'EMPTY_RECEIPT_WRITE)\r?\n\z'), + [Text.RegularExpressions.RegexOptions]::CultureInvariant + ) + if (!$diagnosticMatch.Success) { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' + return $false + } + Write-WatchdogLine ( + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:CLEANUP_VALIDATION_PHASE:' + + $diagnosticMatch.Groups[1].Value + ) + } elseif ($diagnosticDrainResult.StandardOutputBytes -ne 0 -or + $diagnosticDrainResult.StandardOutputLines -ne 0) { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' + return $false + } + } + if ($cleanupProcess.ExitCode -ne 0) { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' + return $false + } + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:COMPLETE' + return $true + } catch { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' + return $false + } finally { + foreach ($resource in @( + $cleanupDiagnosticDrain, $cleanupJob, $cleanupProcess, $cleanupReadyEvent + )) { + if ($null -ne $resource) { try { $resource.Dispose() } catch {} } + } + } +} + +try { + $installerAuthority = Get-InstallerAuthority $Installer + $installerPath = [string]$installerAuthority.Path + if ($OwnershipManifest -or $ExpectedRunId) { + if (!$OwnershipManifest -or $ExpectedRunId -notmatch '^[a-f0-9]{32}$') { + throw 'workflow ownership authority is invalid' + } + $candidateManifestPath = [IO.Path]::GetFullPath($OwnershipManifest) + $tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + if ((Split-Path -Leaf $candidateManifestPath) -cne + "propr-installed-app-ownership-$ExpectedRunId.json" -or + ![string]::Equals( + (Split-Path -Parent $candidateManifestPath).TrimEnd('\'), + $tempRoot, + [StringComparison]::OrdinalIgnoreCase + )) { + throw 'workflow ownership manifest path is invalid' + } + $ownershipManifestPath = $candidateManifestPath + $ownershipRunId = $ExpectedRunId + $workflowManagedManifest = $true + } else { + $ownershipRunId = $generatedRunId + } + $selectedWorkerPath = if ($WorkerPath) { $WorkerPath } else { $productionWorkerPath } + $selectedWorkerPath = (Resolve-Path -LiteralPath $selectedWorkerPath -ErrorAction Stop).Path + $cleanupWorkerPath = (Resolve-Path -LiteralPath $cleanupWorkerPath -ErrorAction Stop).Path + $usingProductionWorker = [string]::Equals( + $selectedWorkerPath, $productionWorkerPath, [StringComparison]::OrdinalIgnoreCase) + if ($FixtureCleanupRoot) { + if ($usingProductionWorker) { throw 'production worker cannot use a fixture cleanup scope' } + $FixtureCleanupRoot = (Resolve-Path -LiteralPath $FixtureCleanupRoot -ErrorAction Stop).Path + $fixtureScenario = [string]$env:PROPR_SUPERVISOR_FIXTURE_SCENARIO + $fixtureNoMarkerDiagnostic = $fixtureScenario -in @( + 'NO_MARKER','NO_MARKER_WINDOWS_POWERSHELL' + ) + $fixtureWindowsPowerShellCleanup = + $fixtureScenario -ceq 'NO_MARKER_WINDOWS_POWERSHELL' + } elseif (!$usingProductionWorker) { + throw 'injected workers require a fixture cleanup scope' + } + if ($InjectTerminationFailure -and $usingProductionWorker) { + throw 'termination failure injection requires an authorized fixture worker' + } + $hostPath = (Get-Process -Id $PID -ErrorAction Stop).Path + if ([IO.Path]::GetFileName($hostPath) -notin @('pwsh.exe', 'powershell.exe')) { + throw 'PowerShell host resolution failed' + } + if ($CancellationEventName) { + if ($CancellationEventName -notmatch '^Local\\ProPRInstalledAppCancellation-[a-f0-9]{32}$') { + throw 'supervisor cancellation event name is invalid' + } + $cancellationEvent = [Threading.EventWaitHandle]::OpenExisting($CancellationEventName) + } + Write-InitialOwnershipManifest ` + $ownershipManifestPath $installerAuthority (!$usingProductionWorker) $FixtureCleanupRoot + + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $hostPath + $startInfo.UseShellExecute = $false + $ownershipReadyEvent = [Threading.EventWaitHandle]::new( + $false, + [Threading.EventResetMode]::ManualReset, + $ownershipReadyEventName + ) + foreach ($argument in @( + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-File', $selectedWorkerPath, + '-Installer', $installerPath, + '-Architecture', $Architecture, + '-WatchdogMarker', $markerPath, + '-OwnershipReadyEvent', $ownershipReadyEventName, + '-OwnershipManifest', $ownershipManifestPath + )) { + $startInfo.ArgumentList.Add($argument) + } + + $job = [ProPRKillOnCloseJob]::new() + $worker = [Diagnostics.Process]::new() + $worker.StartInfo = $startInfo + if (!$worker.Start()) { throw 'installed-app worker did not start' } + $workerStarted = $true + $bootstrapStopwatch = [Diagnostics.Stopwatch]::StartNew() + try { + $job.AddProcess($worker.Handle) + [void]$ownershipReadyEvent.Set() + } catch { + try { $worker.Kill($true) } catch {} + throw 'installed-app worker ownership failed' + } + + $firstMarkerAccepted = $false + while ($true) { + if ($null -ne $cancellationEvent -and $cancellationEvent.WaitOne(0)) { + try { + $cancellationMarker = Read-WatchdogMarker $markerPath $MarkerReadTimeoutMilliseconds + if ($cancellationMarker.State -eq 'Valid' -and + (Test-WatchdogMarkerSchema $cancellationMarker)) { + $lastValidMarker = $cancellationMarker + } + } catch {} + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:SUPERVISOR:CANCELLED' + if (Test-MsiCriticalMarker $lastValidMarker) { + $postTerminationCleanupAuthorized = Wait-MsiCriticalTransactionReceipt + } + $exitCode = 125 + $terminateOwnedTree = $true + break + } + + $waitMilliseconds = $WatchdogPollMilliseconds + if (!$firstMarkerAccepted) { + $remainingBootstrapMilliseconds = $BootstrapTimeoutMilliseconds - + [int]$bootstrapStopwatch.ElapsedMilliseconds + if ($remainingBootstrapMilliseconds -le 0) { $waitMilliseconds = 1 } + else { $waitMilliseconds = [Math]::Min($waitMilliseconds, $remainingBootstrapMilliseconds) } + } + $workerExited = $worker.WaitForExit($waitMilliseconds) + + $readTimeout = $MarkerReadTimeoutMilliseconds + if (!$firstMarkerAccepted) { + $remainingBootstrapMilliseconds = $BootstrapTimeoutMilliseconds - + [int]$bootstrapStopwatch.ElapsedMilliseconds + if ($remainingBootstrapMilliseconds -gt 0) { + $readTimeout = [Math]::Min($readTimeout, $remainingBootstrapMilliseconds) + } else { + $readTimeout = 1 + } + } + $marker = Read-WatchdogMarker $markerPath ([Math]::Max(1, $readTimeout)) + if ($marker.State -eq 'Valid' -and !(Test-WatchdogMarkerSchema $marker)) { + $marker = [PSCustomObject]@{ State = 'Invalid' } + } + + if ($marker.State -eq 'Valid') { + if (!$firstMarkerAccepted) { + if ($bootstrapStopwatch.ElapsedMilliseconds -gt $BootstrapTimeoutMilliseconds -or + !(Test-FreshMarker $marker)) { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:BOOTSTRAP:FAILED' + $exitCode = 124 + $terminateOwnedTree = $true + break + } + $firstMarkerAccepted = $true + } elseif (!(Test-FreshMarker $marker)) { + Write-WatchdogLine ('PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:{0}:{1}:{2}:TIMED_OUT' -f ` + $marker.Stage, $marker.Substage, $marker.Status) + $exitCode = 124 + if (Test-MsiCriticalMarker $marker) { + $postTerminationCleanupAuthorized = Wait-MsiCriticalTransactionReceipt + } + $terminateOwnedTree = $true + break + } + Accept-WatchdogMarker $marker + } elseif (!$firstMarkerAccepted) { + if ($marker.State -in @('Invalid','Inaccessible','TimedOut')) { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:BOOTSTRAP:FAILED' + $exitCode = 124 + $terminateOwnedTree = $true + break + } + if ($workerExited) { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:BOOTSTRAP:FAILED' + $exitCode = 124 + $terminateOwnedTree = $true + break + } + if ($bootstrapStopwatch.ElapsedMilliseconds -ge $BootstrapTimeoutMilliseconds) { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:BOOTSTRAP:TIMED_OUT' + $exitCode = 124 + $terminateOwnedTree = $true + break + } + } else { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:MARKER:FAILED' + $exitCode = 124 + $terminateOwnedTree = $true + break + } + + if ($workerExited) { + $exitCode = $worker.ExitCode + $supervisorOutcomeComplete = $exitCode -eq 0 + break + } + } +} catch { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:SUPERVISOR:FAILED' + $exitCode = 125 + $terminateOwnedTree = $true +} finally { + $workerLive = $false + if ($workerStarted -and $null -ne $worker) { + try { $workerLive = !$worker.HasExited } catch { $workerLive = $true } + } + $cleanupRequired = $terminateOwnedTree -or $workerStarted -or $workerLive -or + !$supervisorOutcomeComplete + $fixedCleanupResult = $null + if ($cleanupRequired -and $installerPath -and $ownershipRunId) { + # Process.ExitCode is signed and can be negative after a native crash. The + # Job Object API requires a valid uint32, so finalization always uses this + # fixed supervisor-owned termination code instead of casting worker status. + $workerTreeTerminated = Stop-OwnedWorker 125 + if ($fixtureNoMarkerDiagnostic) { + $fixtureWorkerTreeTerminationOutcome = if ($workerTreeTerminated) { + 'COMPLETE' + } else { 'FAILED' } + } + if ($workerTreeTerminated -and $postTerminationCleanupAuthorized) { + $fixedCleanupResult = Invoke-PostTerminationCleanup $installerPath $FixtureCleanupRoot + } else { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' + $fixedCleanupResult = $false + } + if ($fixedCleanupResult -ne $true) { $exitCode = 125 } + } + + if ($fixtureNoMarkerDiagnostic) { + Write-WatchdogLine (( + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:FIXTURE_FINALIZATION:' + + 'WORKER_TREE_TERMINATION:{0}') -f $fixtureWorkerTreeTerminationOutcome) + if ($fixtureWorkerTreeTerminationOutcome -ceq 'COMPLETE') { + Write-WatchdogLine (( + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:FIXTURE_FINALIZATION:' + + 'CLEANUP_CHILD_EXIT:{0}') -f $fixtureCleanupChildExitCategory) + } + } + + try { + $finalMarker = Read-WatchdogMarker $markerPath $MarkerReadTimeoutMilliseconds + if ($finalMarker.State -eq 'Valid' -and (Test-WatchdogMarkerSchema $finalMarker) -and + (Test-FreshMarker $finalMarker)) { + $lastValidMarker = $finalMarker + } + } catch {} + if ($null -ne $lastValidMarker) { + Write-WatchdogLine ('PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:LAST_VALID:{0}:{1}:{2}' -f ` + $lastValidMarker.Stage, $lastValidMarker.Substage, $lastValidMarker.Status) + } else { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:LAST_VALID:NONE' + } + + foreach ($resource in @($job, $worker, $ownershipReadyEvent, $cancellationEvent)) { + if ($null -eq $resource) { continue } + try { $resource.Dispose() } catch { + $fixedCleanupResult = $false + $exitCode = 125 + } + } + try { + if ([IO.File]::Exists($markerPath)) { [IO.File]::Delete($markerPath) } + } catch {} + if ($fixedCleanupResult -eq $true -and !$workflowManagedManifest) { + foreach ($path in @($ownershipManifestPath, "$ownershipManifestPath.new")) { + try { if ([IO.File]::Exists($path)) { [IO.File]::Delete($path) } } catch {} + } + } +} + +exit $exitCode diff --git a/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup-body.ps1 b/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup-body.ps1 new file mode 100644 index 000000000..76e6eeeea --- /dev/null +++ b/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup-body.ps1 @@ -0,0 +1,553 @@ +param( + [object]$OwnershipManifest, + [object]$Installer, + [object]$ExpectedRunId, + [object]$CleanupTimeoutMilliseconds = 4 * 60 * 1000, + [object]$TerminationTimeoutMilliseconds = 30 * 1000, + [object]$FixtureRoot, + [switch]$FixtureEarlyInitializationChild +) + +enum WorkflowCleanupControllerPhase { + INITIALIZATION + PARAMETER_VALIDATION + PATH_VALIDATION + PROCESS_START + PROCESS_WAIT + PROCESS_FINALIZATION + STREAM_FINALIZATION + RESOURCE_FINALIZATION + AUTHORITY_FINALIZATION + RESULT_EMISSION +} + +enum WorkflowCleanupControllerLine { + TYPE_LOAD + PARAMETERS + PATHS + START + WAIT + TERMINATE + DRAIN + DISPOSE + AUTHORITY + EMIT +} + +$ErrorActionPreference = 'Stop' +$cleanupProcess = $null +$cleanupJob = $null +$cleanupReadyEvent = $null +$outputDrain = $null +$fixedResult = 'FAILED' +$fixedStatus = 'CONTROLLER_FAILURE' +$fixedExitCode = 125 +$validatedManifestPath = $null +[WorkflowCleanupControllerPhase]$controllerPhase = 'INITIALIZATION' +[WorkflowCleanupControllerLine]$controllerLine = 'TYPE_LOAD' +$cleanupTreeZeroVerified = $false + +function Write-FixedResult([ValidateSet('COMPLETE','FAILED','TIMED_OUT')][string]$Result) { + [Console]::Out.WriteLine("PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:$Result") + [Console]::Out.WriteLine( + 'PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:STATUS:{0}:EXIT_CODE:{1}' -f ` + $script:fixedStatus, $script:fixedExitCode) + [Console]::Out.Flush() +} + +function Set-CaughtControllerFailure($ErrorRecord) { + $phases = @( + 'INITIALIZATION','PARAMETER_VALIDATION','PATH_VALIDATION','PROCESS_START', + 'PROCESS_WAIT','PROCESS_FINALIZATION','STREAM_FINALIZATION', + 'RESOURCE_FINALIZATION','AUTHORITY_FINALIZATION','RESULT_EMISSION' + ) + $lines = @( + 'TYPE_LOAD','PARAMETERS','PATHS','START','WAIT','TERMINATE','DRAIN', + 'DISPOSE','AUTHORITY','EMIT' + ) + $categories = @{ + AuthenticationError = 'AUTHENTICATION' + CloseError = 'CLOSE' + InvalidArgument = 'INVALID_ARGUMENT' + InvalidData = 'INVALID_DATA' + InvalidOperation = 'INVALID_OPERATION' + LimitsExceeded = 'LIMIT' + NotEnabled = 'NOT_ENABLED' + ObjectNotFound = 'NOT_FOUND' + OpenError = 'OPEN' + OperationStopped = 'STOPPED' + PermissionDenied = 'PERMISSION' + ReadError = 'READ' + ResourceBusy = 'BUSY' + ResourceUnavailable = 'UNAVAILABLE' + SecurityError = 'SECURITY' + WriteError = 'WRITE' + } + $phase = if ($phases -ccontains [string]$script:controllerPhase) { + [string]$script:controllerPhase + } else { 'INITIALIZATION' } + $line = if ($lines -ccontains [string]$script:controllerLine) { + [string]$script:controllerLine + } else { 'TYPE_LOAD' } + $categoryName = [string]$ErrorRecord.CategoryInfo.Category + $category = if ($categories.ContainsKey($categoryName)) { + $categories[$categoryName] + } else { 'UNCLASSIFIED' } + $script:fixedResult = 'FAILED' + $script:fixedStatus = 'CONTROLLER_{0}_{1}_{2}' -f $phase, $line, $category + $script:fixedExitCode = 125 +} + +Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Win32.SafeHandles; + +public sealed class ProPRWorkflowCleanupJob : IDisposable +{ + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_BASIC_LIMIT_INFORMATION + { + public long PerProcessUserTimeLimit; + public long PerJobUserTimeLimit; + public uint LimitFlags; + public UIntPtr MinimumWorkingSetSize; + public UIntPtr MaximumWorkingSetSize; + public uint ActiveProcessLimit; + public UIntPtr Affinity; + public uint PriorityClass; + public uint SchedulingClass; + } + + [StructLayout(LayoutKind.Sequential)] + private struct IO_COUNTERS + { + public ulong ReadOperationCount; + public ulong WriteOperationCount; + public ulong OtherOperationCount; + public ulong ReadTransferCount; + public ulong WriteTransferCount; + public ulong OtherTransferCount; + } + + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION + { + public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation; + public IO_COUNTERS IoInfo; + public UIntPtr ProcessMemoryLimit; + public UIntPtr JobMemoryLimit; + public UIntPtr PeakProcessMemoryUsed; + public UIntPtr PeakJobMemoryUsed; + } + + private const int JobObjectExtendedLimitInformation = 9; + private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000; + private SafeFileHandle handle; + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateJobObject(IntPtr attributes, string name); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool SetInformationJobObject( + SafeFileHandle job, int informationClass, IntPtr information, uint informationLength); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool AssignProcessToJobObject(SafeFileHandle job, IntPtr process); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool TerminateJobObject(SafeFileHandle job, uint exitCode); + + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_BASIC_ACCOUNTING_INFORMATION + { + public long TotalUserTime; + public long TotalKernelTime; + public long ThisPeriodTotalUserTime; + public long ThisPeriodTotalKernelTime; + public uint TotalPageFaultCount; + public uint TotalProcesses; + public uint ActiveProcesses; + public uint TotalTerminatedProcesses; + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool QueryInformationJobObject( + SafeFileHandle job, int informationClass, + out JOBOBJECT_BASIC_ACCOUNTING_INFORMATION information, + uint informationLength, IntPtr returnLength); + + public ProPRWorkflowCleanupJob() + { + handle = CreateJobObject(IntPtr.Zero, null); + if (handle == null || handle.IsInvalid) + throw new Win32Exception(Marshal.GetLastWin32Error(), "job creation failed"); + var limits = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION(); + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + int size = Marshal.SizeOf(typeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION)); + IntPtr buffer = Marshal.AllocHGlobal(size); + try + { + Marshal.StructureToPtr(limits, buffer, false); + if (!SetInformationJobObject(handle, JobObjectExtendedLimitInformation, buffer, (uint)size)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "job configuration failed"); + } + finally { Marshal.FreeHGlobal(buffer); } + } + + public void AddProcess(IntPtr processHandle) + { + if (!AssignProcessToJobObject(handle, processHandle)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "cleanup ownership failed"); + } + + private uint ReadActiveProcessCount() + { + if (handle == null || handle.IsInvalid) + throw new InvalidOperationException("job handle is unavailable"); + JOBOBJECT_BASIC_ACCOUNTING_INFORMATION information; + uint size = (uint)Marshal.SizeOf(typeof(JOBOBJECT_BASIC_ACCOUNTING_INFORMATION)); + if (!QueryInformationJobObject(handle, 1, out information, size, IntPtr.Zero)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "cleanup accounting failed"); + return information.ActiveProcesses; + } + + public bool WaitForNoActiveProcesses(int timeoutMilliseconds) + { + var stopwatch = Stopwatch.StartNew(); + do + { + if (ReadActiveProcessCount() == 0) return true; + Thread.Sleep(25); + } + while (stopwatch.ElapsedMilliseconds < timeoutMilliseconds); + return ReadActiveProcessCount() == 0; + } + + public bool HasNoActiveProcesses() + { + return ReadActiveProcessCount() == 0; + } + + public bool TerminateAndWait(uint exitCode, int timeoutMilliseconds) + { + if (handle == null || handle.IsInvalid) + throw new InvalidOperationException("job handle is unavailable"); + if (!TerminateJobObject(handle, exitCode)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "cleanup termination failed"); + return WaitForNoActiveProcesses(timeoutMilliseconds); + } + + public void Dispose() { if (handle != null) handle.Dispose(); } +} + +public sealed class ProPRWorkflowCleanupDrainResult +{ + public long StandardOutputCharacters; + public long StandardErrorCharacters; +} + +public sealed class ProPRWorkflowCleanupOutputDrain : IDisposable +{ + private const long CharacterLimit = 4096; + private readonly CancellationTokenSource cancellation = new CancellationTokenSource(); + private StreamReader standardOutputReader; + private StreamReader standardErrorReader; + private Task standardOutputTask; + private Task standardErrorTask; + + private static async Task Pump(StreamReader reader, CancellationToken token) + { + var buffer = new char[1024]; + long characters = 0; + while (true) + { + int count = await reader.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false); + if (count == 0) return characters; + token.ThrowIfCancellationRequested(); + characters = Math.Min(CharacterLimit + 1, characters + count); + } + } + + public void Start(Process process) + { + if (standardOutputTask != null || standardErrorTask != null) + throw new InvalidOperationException("stream drain was already started"); + standardOutputReader = process.StandardOutput; + standardErrorReader = process.StandardError; + standardOutputTask = Pump(standardOutputReader, cancellation.Token); + standardErrorTask = Pump(standardErrorReader, cancellation.Token); + } + + public ProPRWorkflowCleanupDrainResult Finish(int timeoutMilliseconds) + { + if (standardOutputTask == null || standardErrorTask == null) + throw new InvalidOperationException("stream drain was not started"); + Task all = Task.WhenAll(standardOutputTask, standardErrorTask); + if (!all.Wait(timeoutMilliseconds)) return null; + if (standardOutputTask.IsFaulted || standardOutputTask.IsCanceled || + standardErrorTask.IsFaulted || standardErrorTask.IsCanceled) + throw new InvalidOperationException("stream drain failed"); + return new ProPRWorkflowCleanupDrainResult { + StandardOutputCharacters = standardOutputTask.Result, + StandardErrorCharacters = standardErrorTask.Result + }; + } + + public bool CancelAndFinish(int timeoutMilliseconds) + { + cancellation.Cancel(); + try { if (standardOutputReader != null) standardOutputReader.Dispose(); } catch { } + try { if (standardErrorReader != null) standardErrorReader.Dispose(); } catch { } + if (standardOutputTask == null || standardErrorTask == null) return true; + try { Task.WhenAll(standardOutputTask, standardErrorTask).Wait(timeoutMilliseconds); } + catch { } + return standardOutputTask.IsCompleted && standardErrorTask.IsCompleted; + } + + public void Dispose() + { + CancelAndFinish(1000); + cancellation.Dispose(); + } +} +'@ + +try { +$controllerPhase = 'PARAMETER_VALIDATION' +$controllerLine = 'PARAMETERS' +$cleanupTimeout = 0 +$terminationTimeout = 0 +if ([string]::IsNullOrWhiteSpace([string]$OwnershipManifest) -or + [string]::IsNullOrWhiteSpace([string]$Installer) -or + [string]::IsNullOrWhiteSpace([string]$ExpectedRunId) -or + ![int]::TryParse( + [string]$CleanupTimeoutMilliseconds, + [Globalization.NumberStyles]::None, + [Globalization.CultureInfo]::InvariantCulture, + [ref]$cleanupTimeout + ) -or $cleanupTimeout -lt 1 -or $cleanupTimeout -gt 600000 -or + ![int]::TryParse( + [string]$TerminationTimeoutMilliseconds, + [Globalization.NumberStyles]::None, + [Globalization.CultureInfo]::InvariantCulture, + [ref]$terminationTimeout + ) -or $terminationTimeout -lt 1 -or $terminationTimeout -gt 30000) { + throw 'workflow cleanup controller parameters are invalid' +} +$OwnershipManifest = [string]$OwnershipManifest +$Installer = [string]$Installer +$ExpectedRunId = [string]$ExpectedRunId +$FixtureRoot = if ($null -eq $FixtureRoot) { $null } else { [string]$FixtureRoot } +$CleanupTimeoutMilliseconds = $cleanupTimeout +$TerminationTimeoutMilliseconds = $terminationTimeout + + $controllerPhase = 'PATH_VALIDATION' + $controllerLine = 'PATHS' + if ($ExpectedRunId -notmatch '^[a-f0-9]{32}$') { throw 'cleanup run identity is invalid' } + $manifestPath = [IO.Path]::GetFullPath($OwnershipManifest) + $tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + if ((Split-Path -Leaf $manifestPath) -cne + "propr-installed-app-ownership-$ExpectedRunId.json" -or + ![string]::Equals( + (Split-Path -Parent $manifestPath).TrimEnd('\'), + $tempRoot, + [StringComparison]::OrdinalIgnoreCase + )) { + throw 'cleanup manifest path is invalid' + } + $validatedManifestPath = $manifestPath + $installerPath = (Resolve-Path -LiteralPath $Installer -ErrorAction Stop).Path + $cleanupWorkerCandidatePath = Join-Path $PSScriptRoot 'cleanup-installed-windows-app.ps1' + $cleanupWorkerPath = (Resolve-Path -LiteralPath $cleanupWorkerCandidatePath -ErrorAction Stop).Path + $hostPath = (Get-Process -Id $PID -ErrorAction Stop).Path + if ([IO.Path]::GetFileName($hostPath) -notin @('pwsh.exe', 'powershell.exe')) { + throw 'PowerShell host resolution failed' + } + $cleanupReadyEventName = "Local\ProPRInstalledAppCleanup-$([Guid]::NewGuid().ToString('N'))" + $cleanupReadyEvent = [Threading.EventWaitHandle]::new( + $false, + [Threading.EventResetMode]::ManualReset, + $cleanupReadyEventName + ) + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $hostPath + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + foreach ($argument in @( + '-NoLogo', '-NoProfile', '-NonInteractive', '-File', $cleanupWorkerPath, + '-OwnershipManifest', $manifestPath, + '-Installer', $installerPath, + '-ExpectedRunId', $ExpectedRunId, + '-OwnershipReadyEvent', $cleanupReadyEventName + )) { + $startInfo.ArgumentList.Add($argument) + } + if ($FixtureRoot) { + $startInfo.ArgumentList.Add('-FixtureRoot') + $startInfo.ArgumentList.Add((Resolve-Path -LiteralPath $FixtureRoot -ErrorAction Stop).Path) + } + if ($FixtureEarlyInitializationChild) { + if (!$FixtureRoot) { throw 'early initialization fixture requires a fixture scope' } + $startInfo.ArgumentList.Add('-FixtureEarlyInitializationChild') + } + $cleanupJob = [ProPRWorkflowCleanupJob]::new() + $controllerPhase = 'PROCESS_START' + $controllerLine = 'START' + $cleanupProcess = [Diagnostics.Process]::new() + $cleanupProcess.StartInfo = $startInfo + if (!$cleanupProcess.Start()) { throw 'workflow cleanup did not start' } + try { + $cleanupJob.AddProcess($cleanupProcess.Handle) + $outputDrain = [ProPRWorkflowCleanupOutputDrain]::new() + $outputDrain.Start($cleanupProcess) + [void]$cleanupReadyEvent.Set() + } catch { + try { + $cleanupTreeZeroVerified = $cleanupJob.TerminateAndWait( + 125, $TerminationTimeoutMilliseconds) + } catch {} + try { + if (!$cleanupProcess.HasExited) { + $cleanupProcess.Kill($true) + [void]$cleanupProcess.WaitForExit($TerminationTimeoutMilliseconds) + } + } catch {} + throw 'workflow cleanup ownership failed' + } + $controllerPhase = 'PROCESS_WAIT' + $controllerLine = 'WAIT' + if (!$cleanupProcess.WaitForExit($CleanupTimeoutMilliseconds)) { + $controllerLine = 'TERMINATE' + $terminationVerified = $false + try { + $terminationVerified = $cleanupJob.TerminateAndWait( + 125, $TerminationTimeoutMilliseconds) + } catch {} + if ($terminationVerified) { + $cleanupTreeZeroVerified = $true + $fixedResult = 'TIMED_OUT' + $fixedStatus = 'TIMEOUT' + $fixedExitCode = 124 + } else { + $fixedResult = 'FAILED' + $fixedStatus = 'TERMINATION_FAILURE' + $fixedExitCode = 125 + } + } else { + $cleanupTreeZeroVerified = $cleanupJob.HasNoActiveProcesses() + if (!$cleanupTreeZeroVerified) { + try { + $cleanupTreeZeroVerified = $cleanupJob.TerminateAndWait( + 125, $TerminationTimeoutMilliseconds) + } catch {} + $fixedResult = 'FAILED' + $fixedStatus = 'ACTIVE_PROCESS_AFTER_ROOT_EXIT' + $fixedExitCode = 125 + } elseif ($cleanupProcess.ExitCode -eq 0) { + $fixedResult = 'COMPLETE' + $fixedStatus = 'EMPTY_OR_CLEANED' + $fixedExitCode = 0 + } elseif ($cleanupProcess.ExitCode -eq 20) { + $fixedStatus = 'MANIFEST_VALIDATION_FAILURE' + $fixedExitCode = 20 + } elseif ($cleanupProcess.ExitCode -eq 21) { + $fixedStatus = 'OWNED_RESOURCE_CLEANUP_FAILURE' + $fixedExitCode = 21 + } + } +} catch { + Set-CaughtControllerFailure $_ +} + +try { + $controllerPhase = 'PROCESS_FINALIZATION' + $controllerLine = 'TERMINATE' + if ($null -ne $cleanupJob -and !$cleanupTreeZeroVerified) { + $cleanupTreeZeroVerified = $cleanupJob.TerminateAndWait( + 125, $TerminationTimeoutMilliseconds) + if (!$cleanupTreeZeroVerified) { + $fixedResult = 'FAILED' + $fixedStatus = 'PROCESS_FINALIZATION_TIMEOUT' + $fixedExitCode = 125 + } + } +} catch { + $fixedResult = 'FAILED' + $fixedStatus = 'PROCESS_FINALIZATION_FAILURE' + $fixedExitCode = 125 +} + +try { + $controllerPhase = 'STREAM_FINALIZATION' + $controllerLine = 'DRAIN' + if ($null -ne $outputDrain) { + $drainResult = $outputDrain.Finish($TerminationTimeoutMilliseconds) + if ($null -eq $drainResult) { + [void]$outputDrain.CancelAndFinish($TerminationTimeoutMilliseconds) + $fixedResult = 'FAILED' + $fixedStatus = 'STREAM_DRAIN_TIMEOUT' + $fixedExitCode = 125 + } elseif ($drainResult.StandardErrorCharacters -ne 0) { + $fixedResult = 'FAILED' + $fixedStatus = if ($drainResult.StandardErrorCharacters -gt 4096) { + 'CHILD_STDERR_LIMIT' + } else { 'CHILD_STDERR' } + $fixedExitCode = 123 + } elseif ($drainResult.StandardOutputCharacters -ne 0) { + $fixedResult = 'FAILED' + $fixedStatus = if ($drainResult.StandardOutputCharacters -gt 4096) { + 'CHILD_STDOUT_LIMIT' + } else { 'CHILD_STDOUT' } + $fixedExitCode = 122 + } + } +} catch { + $fixedResult = 'FAILED' + $fixedStatus = 'STREAM_DRAIN_FAILURE' + $fixedExitCode = 125 +} + +$controllerPhase = 'RESOURCE_FINALIZATION' +$controllerLine = 'DISPOSE' +foreach ($resource in @($outputDrain, $cleanupJob, $cleanupProcess, $cleanupReadyEvent)) { + if ($null -eq $resource) { continue } + try { $resource.Dispose() } catch { + $fixedResult = 'FAILED' + $fixedStatus = 'RESOURCE_FINALIZATION_FAILURE' + $fixedExitCode = 125 + } +} + +if ($fixedResult -ceq 'COMPLETE' -and $cleanupTreeZeroVerified -and + $validatedManifestPath) { + try { + $controllerPhase = 'AUTHORITY_FINALIZATION' + $controllerLine = 'AUTHORITY' + foreach ($path in @("$validatedManifestPath.new", $validatedManifestPath)) { + if ([IO.File]::Exists($path)) { [IO.File]::Delete($path) } + } + } catch { + $fixedResult = 'FAILED' + $fixedStatus = 'AUTHORITY_FINALIZATION_FAILURE' + $fixedExitCode = 125 + } +} + +try { + $controllerPhase = 'RESULT_EMISSION' + $controllerLine = 'EMIT' + Write-FixedResult $fixedResult +} catch { + Set-CaughtControllerFailure $_ + exit 125 +} + +exit $fixedExitCode diff --git a/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 b/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 new file mode 100644 index 000000000..e96daa0e8 --- /dev/null +++ b/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 @@ -0,0 +1,90 @@ +param( + [object]$OwnershipManifest, + [object]$Installer, + [object]$ExpectedRunId, + [object]$CleanupTimeoutMilliseconds = 4 * 60 * 1000, + [object]$TerminationTimeoutMilliseconds = 30 * 1000, + [object]$FixtureRoot, + [object]$FixtureEarlyInitializationChild, + [object]$StartupFailureClass +) + +$ErrorActionPreference = 'Stop' +$bodyPath = Join-Path $PSScriptRoot 'run-installed-windows-app-workflow-cleanup-body.ps1' + +function Get-StartupFailureClass($ErrorRecord) { + $exception = $ErrorRecord.Exception + while ($null -ne $exception) { + if ($exception -is [Management.Automation.ParseException]) { return 'PARSER' } + if ($exception -is [Management.Automation.ParameterBindingException]) { + return 'PARAMETER_BINDING' + } + if ($exception -is [TypeLoadException] -or + $exception -is [TypeInitializationException] -or + $exception -is [IO.FileLoadException]) { + return 'TYPE_LOAD' + } + $exception = $exception.InnerException + } + return 'OTHER' +} + +function Write-StartupFailure($ErrorRecord) { + $failureClass = Get-StartupFailureClass $ErrorRecord + $line = 0 + try { + $candidateLine = [int64]$ErrorRecord.InvocationInfo.ScriptLineNumber + if ($candidateLine -ge 0 -and $candidateLine -le 999999) { $line = $candidateLine } + } catch {} + [Console]::Out.WriteLine('PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:FAILED') + [Console]::Out.WriteLine(( + ('PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:STATUS:STARTUP_FAILURE:' + + 'EXIT_CODE:125:STARTUP_CLASS:{0}:PROCESS_EXIT:125:LINE:{1}') -f ` + $failureClass, $line + )) + [Console]::Out.Flush() +} + +try { + if ($null -ne $StartupFailureClass) { + switch ([string]$StartupFailureClass) { + 'PARSER' { [void][scriptblock]::Create('{') } + 'PARAMETER_BINDING' { + function Invoke-StartupBindingProbe { + param([Parameter(Mandatory=$true)][int]$Value) + } + Invoke-StartupBindingProbe -Value ([object]::new()) + } + 'TYPE_LOAD' { throw [TypeLoadException]::new('startup type-load fixture') } + 'OTHER' { throw [InvalidOperationException]::new('startup other fixture') } + default { throw [InvalidOperationException]::new('startup fixture class is invalid') } + } + } + $bodyParameters = @{ + OwnershipManifest = $OwnershipManifest + Installer = $Installer + ExpectedRunId = $ExpectedRunId + CleanupTimeoutMilliseconds = $CleanupTimeoutMilliseconds + TerminationTimeoutMilliseconds = $TerminationTimeoutMilliseconds + FixtureRoot = $FixtureRoot + } + if ([bool]$FixtureEarlyInitializationChild) { + $bodyParameters.FixtureEarlyInitializationChild = $true + } + $LASTEXITCODE = $null + & $bodyPath @bodyParameters + $bodyExitCode = 0 + if ($null -eq $LASTEXITCODE -or + ![int]::TryParse( + [string]$LASTEXITCODE, + [Globalization.NumberStyles]::AllowLeadingSign, + [Globalization.CultureInfo]::InvariantCulture, + [ref]$bodyExitCode + ) -or $bodyExitCode -notin @(0,20,21,122,123,124,125)) { + throw [InvalidOperationException]::new('workflow cleanup body returned without a fixed exit') + } + exit $bodyExitCode +} catch { + Write-StartupFailure $_ + exit 125 +} diff --git a/apps/desktop/scripts/run-native-durability.mjs b/apps/desktop/scripts/run-native-durability.mjs index 568106ddc..c4a5e1832 100644 --- a/apps/desktop/scripts/run-native-durability.mjs +++ b/apps/desktop/scripts/run-native-durability.mjs @@ -2,9 +2,10 @@ import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; const EXPECTED = Object.freeze({ - 'credential-service': 69, + 'credential-service': 72, 'profile-store': 37, 'pairing-shutdown': 10, + 'pairing-browser': 1, }); const expectedTotal = Object.values(EXPECTED).reduce((total, count) => total + count, 0); const tsxCli = fileURLToPath(import.meta.resolve('tsx/cli')); @@ -15,6 +16,7 @@ const child = spawn(process.execPath, [ 'src/profile-store.test.ts', 'src/credential-service.test.ts', 'src/pairing-response-lifecycle.test.ts', + 'src/credential-service.pairing-browser.test.ts', ], { cwd: fileURLToPath(new URL('..', import.meta.url)), env: process.env, @@ -51,6 +53,7 @@ const executed = { 'credential-service': plannedForSuite('main-process desktop credential service'), 'profile-store': plannedForSuite('desktop profile store'), 'pairing-shutdown': plannedForSuite('desktop pairing service IPC native shutdown lifecycle'), + 'pairing-browser': plannedForSuite('DesktopCredentialService pairing browser sink'), }; const reportedCategory = (category) => { const match = output.match(new RegExp( diff --git a/apps/desktop/scripts/smoke-packaged.mjs b/apps/desktop/scripts/smoke-packaged.mjs index 850b202d3..40880661b 100644 --- a/apps/desktop/scripts/smoke-packaged.mjs +++ b/apps/desktop/scripts/smoke-packaged.mjs @@ -103,11 +103,14 @@ const corsHeaders = { 'Cache-Control': 'no-store', 'Content-Type': 'application/json', }; -const discovery = JSON.stringify({ +const discovery = publicInstanceIdentity => JSON.stringify({ + schemaVersion: 1, product: 'ProPR', version: '0.8.15', apiCompatibility: PROPR_API_COMPATIBILITY, uiCompatibility: PROPR_UI_COMPATIBILITY, + canonicalEndpoint: null, + publicInstanceIdentity, desktopAuthentication: { protocolVersion: 2, browserPairing: true, @@ -182,7 +185,9 @@ const listenFixture = async name => { } if (request.url === '/api/desktop/discovery') { response.writeHead(200, { ...corsHeaders, 'Set-Cookie': 'discovery=must-not-persist; HttpOnly; SameSite=None' }); - response.end(discovery); + response.end(discovery(name === 'first' + ? 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + : 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb')); return; } if (request.method === 'DELETE' && request.url === '/api/desktop/tokens/current') { diff --git a/apps/desktop/scripts/test-installed-windows-app-supervisor-fixture.ps1 b/apps/desktop/scripts/test-installed-windows-app-supervisor-fixture.ps1 new file mode 100644 index 000000000..ee1de9bb9 --- /dev/null +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor-fixture.ps1 @@ -0,0 +1,1016 @@ +param( + [Parameter(Mandatory=$true)][string]$Installer, + [Parameter(Mandatory=$true)][ValidateSet('x64','arm64')][string]$Architecture, + [Parameter(Mandatory=$true)][string]$WatchdogMarker, + [Parameter(Mandatory=$true)][string]$OwnershipReadyEvent, + [Parameter(Mandatory=$true)][string]$OwnershipManifest +) + +$ErrorActionPreference = 'Stop' + +function Initialize-FixtureDirectoryIdentity { + if ('ProPRFixtureDirectoryIdentity' -as [type]) { return } + Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +public static class ProPRFixtureDirectoryIdentity +{ + [StructLayout(LayoutKind.Sequential)] + private struct BY_HANDLE_FILE_INFORMATION + { + public uint FileAttributes; + public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime; + public uint VolumeSerialNumber; + public uint FileSizeHigh; + public uint FileSizeLow; + public uint NumberOfLinks; + public uint FileIndexHigh; + public uint FileIndexLow; + } + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFile( + string path, uint access, uint share, IntPtr security, uint creation, + uint flags, IntPtr template); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetFileInformationByHandle( + SafeFileHandle handle, out BY_HANDLE_FILE_INFORMATION information); + public static string ReadEntry(string path, bool expectDirectory) + { + using (SafeFileHandle handle = CreateFile( + path, 0x80, 0x7, IntPtr.Zero, 3, 0x02200000, IntPtr.Zero)) + { + if (handle == null || handle.IsInvalid) + throw new Win32Exception(Marshal.GetLastWin32Error()); + BY_HANDLE_FILE_INFORMATION information; + if (!GetFileInformationByHandle(handle, out information)) + throw new Win32Exception(Marshal.GetLastWin32Error()); + bool isDirectory = (information.FileAttributes & 0x10) != 0; + if ((information.FileAttributes & 0x400) != 0 || + isDirectory != expectDirectory) + throw new InvalidOperationException("fixture entry identity changed"); + return string.Format("{0:x8}{1:x8}{2:x8}", information.VolumeSerialNumber, + information.FileIndexHigh, information.FileIndexLow); + } + } + public static string Read(string path) { return ReadEntry(path, true); } +} +'@ +} +$scenario = $env:PROPR_SUPERVISOR_FIXTURE_SCENARIO +$stateDirectory = $env:PROPR_SUPERVISOR_FIXTURE_STATE_DIRECTORY +if ($scenario -notin @( + 'NO_MARKER', + 'NO_MARKER_WINDOWS_POWERSHELL', + 'VALID_THEN_DEADLINE', + 'MALFORMED_MARKER', + 'TORN_MARKER', + 'STALE_MARKER', + 'INACCESSIBLE_MARKER', + 'NEGATIVE_EXIT', + 'CANCELLATION', + 'DURING_MSI', + 'DURING_OWNERSHIP_CAPTURE', + 'OWNED_RESOURCES_NORMAL_SUCCESS', + 'OWNED_RESOURCES_FOR_INTERRUPTION', + 'SMOKE_BEFORE_PROMOTION_THEN_DEADLINE', + 'SMOKE_AFTER_PROMOTION_THEN_DEADLINE', + 'SMOKE_AFTER_ARTIFACTS_THEN_DEADLINE', + 'SMOKE_FOREIGN_DESCENDANT_THEN_DEADLINE', + 'SMOKE_TOKEN_MISMATCH_THEN_DEADLINE', + 'PRIMARY_FALLBACK_FOREIGN_DESCENDANTS', + 'OWNED_RESOURCES_FOREIGN_CHILD_THEN_DEADLINE', + 'OWNED_EXECUTABLE_REPLACED_THEN_DEADLINE', + 'OWNED_EXECUTABLE_BYTE_IDENTICAL_REPLACED_THEN_DEADLINE', + 'OWNED_SHORTCUT_REPLACED_THEN_DEADLINE', + 'OWNED_PROFILE_PATH_MISMATCH_THEN_DEADLINE', + 'OWNED_RESOURCES_REPLACED_THEN_DEADLINE', + 'OWNED_RESOURCES_THEN_DEADLINE' + )) { + throw 'fixture scenario is invalid' +} +if (!$stateDirectory -or !(Test-Path -LiteralPath $stateDirectory -PathType Container)) { + throw 'fixture state directory is invalid' +} + +function Write-FixtureMarker([string]$Record) { + $temporaryMarker = "$WatchdogMarker.$PID.new" + $bytes = [Text.Encoding]::ASCII.GetBytes($Record) + $stream = [IO.FileStream]::new( + $temporaryMarker, + [IO.FileMode]::Create, + [IO.FileAccess]::Write, + [IO.FileShare]::None, + 4096, + [IO.FileOptions]::WriteThrough + ) + try { + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + } finally { + $stream.Dispose() + } + [IO.File]::Move($temporaryMarker, $WatchdogMarker, $true) +} + +function Write-FixtureOwnershipManifest($Manifest) { + $temporaryManifest = "$OwnershipManifest.new" + $bytes = [Text.Encoding]::UTF8.GetBytes(($Manifest | ConvertTo-Json -Depth 6 -Compress)) + $stream = [IO.FileStream]::new( + $temporaryManifest, + [IO.FileMode]::Create, + [IO.FileAccess]::Write, + [IO.FileShare]::None, + 4096, + [IO.FileOptions]::WriteThrough + ) + try { + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + } finally { + $stream.Dispose() + } + [IO.File]::Move($temporaryManifest, $OwnershipManifest, $true) +} + +function Write-FixtureCriticalGate([string]$Name) { + [IO.File]::WriteAllText( + (Join-Path $stateDirectory 'critical-gate.txt'), + $Name, + [Text.Encoding]::ASCII + ) +} + +function Write-FixtureOwnershipToken([string]$Path, [string]$Token) { + $bytes = [Text.Encoding]::ASCII.GetBytes($Token) + $stream = [IO.FileStream]::new( + $Path, + [IO.FileMode]::CreateNew, + [IO.FileAccess]::Write, + [IO.FileShare]::Read, + 4096, + [IO.FileOptions]::WriteThrough + ) + try { + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + } finally { + $stream.Dispose() + } +} + +function Get-FixtureFileIdentity([string]$Path) { + $stream = [IO.File]::OpenRead($Path) + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + return [BitConverter]::ToString($sha256.ComputeHash($stream)).Replace('-', '').ToLowerInvariant() + } finally { + $sha256.Dispose() + $stream.Dispose() + } +} + +function Get-FixtureEntryIdentity([string]$Path, [bool]$Directory) { + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if ($item.PSIsContainer -ne $Directory -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'fixture file-system object identity is invalid' + } + return [ProPRFixtureDirectoryIdentity]::ReadEntry($item.FullName, $Directory) +} + +function Get-FixtureTreeIdentity([string]$Path) { + $root = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if (!$root.PSIsContainer -or + ($root.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'fixture tree root identity is invalid' + } + $rootPath = $root.FullName.TrimEnd('\') + $records = [Collections.Generic.List[string]]::new() + $records.Add(('D||{0}' -f (Get-FixtureEntryIdentity $rootPath $true))) + foreach ($entry in @(Get-ChildItem -LiteralPath $rootPath -Recurse -Force -ErrorAction Stop)) { + if (($entry.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'fixture tree contains a reparse point' + } + $relativePath = $entry.FullName.Substring($rootPath.Length).TrimStart('\') + $kind = if ($entry.PSIsContainer) { 'D' } else { 'F' } + $relative = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($relativePath)) + $identity = Get-FixtureEntryIdentity $entry.FullName ([bool]$entry.PSIsContainer) + $records.Add(('{0}|{1}|{2}' -f $kind, $relative, $identity)) + } + $recordArray = $records.ToArray() + [Array]::Sort($recordArray, [StringComparer]::Ordinal) + $payload = [Text.Encoding]::UTF8.GetBytes(($recordArray -join "`n")) + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + return [BitConverter]::ToString($sha256.ComputeHash($payload)).Replace('-', '').ToLowerInvariant() + } finally { $sha256.Dispose() } +} + +function Set-FixtureSmokeAcl([string]$Path, [string]$UserSid) { + $administratorsSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') + $systemSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-18') + $acl = [Security.AccessControl.DirectorySecurity]::new() + $acl.SetAccessRuleProtection($true, $false) + $acl.SetOwner($administratorsSid) + $inheritance = [Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit' + foreach ($sid in @( + [Security.Principal.SecurityIdentifier]::new($UserSid), + $systemSid, + $administratorsSid + )) { + $rule = [Security.AccessControl.FileSystemAccessRule]::new( + $sid, + [Security.AccessControl.FileSystemRights]::FullControl, + $inheritance, + [Security.AccessControl.PropagationFlags]::None, + [Security.AccessControl.AccessControlType]::Allow + ) + [void]$acl.AddAccessRule($rule) + } + Set-Acl -LiteralPath $Path -AclObject $acl -ErrorAction Stop +} + +function New-FixtureSmokeArtifacts([string]$Path) { + $electronData = Join-Path $Path 'profile\AppData\Local\ProPR' + [void](New-Item -ItemType Directory -Path $electronData -Force -ErrorAction Stop) + [IO.File]::WriteAllText( + (Join-Path $Path 'application.stdout.log'), 'owned-log', [Text.Encoding]::ASCII) + [IO.File]::WriteAllText( + (Join-Path $Path 'application.smoke-evidence.jsonl'), + '{"event":"desktop.smoke.authorized"}', [Text.Encoding]::UTF8) + [IO.File]::WriteAllText( + (Join-Path $electronData 'electron-data.json'), 'owned-electron-data', [Text.Encoding]::ASCII) +} + +function New-OwnedFixtureResources( + [ValidateSet('BEFORE_PROMOTION','AFTER_PROMOTION','AFTER_ARTIFACTS')] + [string]$SmokeCheckpoint = 'AFTER_ARTIFACTS', + [bool]$PublishCommittedReceipt = $true +) { + Initialize-FixtureDirectoryIdentity + $manifest = [IO.File]::ReadAllText($OwnershipManifest, [Text.Encoding]::UTF8) | + ConvertFrom-Json -ErrorAction Stop + if (!$manifest.Fixture -or $manifest.SchemaVersion -ne 3 -or + [string]$manifest.InstallerEntryIdentity -notmatch '^[a-f0-9]{24}$' -or + [string]$manifest.InstallerSha256 -notmatch '^[a-f0-9]{64}$' -or + [string]$manifest.InstallerProductCode -notmatch + '^\{[A-F0-9]{8}(?:-[A-F0-9]{4}){3}-[A-F0-9]{12}\}$' -or + $manifest.ManifestType -cne 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP' -or + $manifest.State -cne 'ACTIVE') { + throw 'fixture ownership manifest was not initialized' + } + $token = [Guid]::NewGuid().ToString('N') + $ownedRoot = Join-Path $stateDirectory 'owned' + $installRoot = Join-Path $ownedRoot 'install-tree' + $executable = Join-Path $installRoot 'propr-desktop.exe' + $shortcutFolder = Join-Path $ownedRoot 'shortcut-folder' + $shortcut = Join-Path $shortcutFolder 'ProPR Desktop.lnk' + $smokeDirectory = Join-Path $ownedRoot 'smoke-data' + [void](New-Item -ItemType Directory -Path $ownedRoot -Force -ErrorAction Stop) + Write-FixtureOwnershipToken (Join-Path $ownedRoot '.propr-installed-app-owner') $token + foreach ($directory in @($installRoot, $shortcutFolder)) { + [void](New-Item -ItemType Directory -Path $directory -Force -ErrorAction Stop) + Write-FixtureOwnershipToken (Join-Path $directory '.propr-installed-app-owner') $token + } + [IO.File]::WriteAllText($executable, 'owned-executable', [Text.Encoding]::ASCII) + [IO.File]::WriteAllText($shortcut, 'owned-shortcut', [Text.Encoding]::ASCII) + + $registryPath = "Registry::HKEY_LOCAL_MACHINE\Software\ProPRSupervisorFixture\$($manifest.RunId)\owned" + [void](New-Item -Path $registryPath -Force -ErrorAction Stop) + Set-ItemProperty -LiteralPath $registryPath -Name 'ProPRInstalledAppOwner' -Value $token + Set-ItemProperty -LiteralPath $registryPath -Name 'Payload' -Value 'owned' + + $userName = $env:PROPR_SUPERVISOR_FIXTURE_OWNED_USER + $passwordText = $env:PROPR_SUPERVISOR_FIXTURE_OWNED_PASSWORD + if ($userName -notmatch '^prpr[a-f0-9]{8}$' -or !$passwordText) { + throw 'fixture owned-user identity is invalid' + } + $password = ConvertTo-SecureString $passwordText -AsPlainText -Force + if (Get-LocalUser -Name $userName -ErrorAction SilentlyContinue) { + throw 'fixture owned-user baseline was not clean' + } + $userOwnershipMarker = + "prpr-own-$([Guid]::NewGuid().ToString('N'))" + $provisionalUserRecord = [ordered]@{ + Name = $userName + Sid = $null + Owned = $true + Provisional = $true + OwnershipMarker = $userOwnershipMarker + } + $manifest.Users = @($provisionalUserRecord) + Write-FixtureOwnershipManifest $manifest + New-LocalUser -Name $userName -Password $password ` + -Description $userOwnershipMarker ` + -AccountNeverExpires -PasswordNeverExpires | Out-Null + $userSid = (Get-LocalUser -Name $userName -ErrorAction Stop).SID.Value + $provisionalUserRecord.Sid = $userSid + $provisionalUserRecord.Provisional = $false + + $smokeRecord = [ordered]@{ + Kind = 'SMOKE_DATA' + Path = $smokeDirectory + Owned = $true + Token = $token + Identity = $null + Provisional = $true + UserSid = $userSid + CreatorSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value + RootOwnerSid = 'S-1-5-32-544' + } + $manifest.Directories = @($smokeRecord) + $manifest.Users = @($provisionalUserRecord) + Write-FixtureOwnershipManifest $manifest + [void](New-Item -ItemType Directory -Path $smokeDirectory -ErrorAction Stop) + Set-FixtureSmokeAcl $smokeDirectory $userSid + Write-FixtureOwnershipToken (Join-Path $smokeDirectory '.propr-installed-app-owner') $token + if ($SmokeCheckpoint -ne 'BEFORE_PROMOTION') { + $smokeRecord.Identity = [ProPRFixtureDirectoryIdentity]::Read($smokeDirectory) + $smokeRecord.Provisional = $false + Write-FixtureOwnershipManifest $manifest + if ($SmokeCheckpoint -eq 'AFTER_ARTIFACTS') { + New-FixtureSmokeArtifacts $smokeDirectory + } + } + + $ownedDirectories = @( + [ordered]@{ Kind = 'FIXTURE_ROOT'; Path = $ownedRoot; Owned = $true; Token = $token }, + [ordered]@{ + Kind = 'INSTALL_ROOT'; Path = $installRoot; Owned = $true; Token = $token + Identity = (Get-FixtureEntryIdentity $installRoot $true) + TreeIdentity = (Get-FixtureTreeIdentity $installRoot); Provisional = $false + }, + [ordered]@{ + Kind = 'SHORTCUT_FOLDER'; Path = $shortcutFolder; Owned = $true; Token = $token + Identity = (Get-FixtureEntryIdentity $shortcutFolder $true) + TreeIdentity = (Get-FixtureTreeIdentity $shortcutFolder); Provisional = $false + }, + $smokeRecord + ) + $conflictingDirectories = @( + $env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_DIRECTORIES -split '\|' | Where-Object { $_ } + ) | ForEach-Object { + [ordered]@{ Kind = 'CONFLICT'; Path = $_; Owned = $false; Token = $null } + } + $manifest.Directories = @($ownedDirectories) + @($conflictingDirectories) + $manifest.Files = @( + [ordered]@{ + Kind = 'FIXTURE_FILE'; Path = $executable + Owned = $true; Token = $null + Identity = (Get-FixtureFileIdentity $executable) + EntryIdentity = (Get-FixtureEntryIdentity $executable $false) + Provisional = $false + }, + [ordered]@{ + Kind = 'SHORTCUT_FILE'; Path = $shortcut; Owned = $true; Token = $token + Identity = (Get-FixtureFileIdentity $shortcut) + EntryIdentity = (Get-FixtureEntryIdentity $shortcut $false) + Provisional = $false + } + ) + if ($env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_SHORTCUT) { + $manifest.Files += [ordered]@{ + Kind = 'CONFLICT'; Path = $env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_SHORTCUT + Owned = $false; Token = $null + } + } + $manifest.RegistryKeys = @( + [ordered]@{ Kind = 'PROTOCOL'; Path = $registryPath; Owned = $true; Token = $token } + ) + $manifest.RegistryValues = @() + if ($env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_REGISTRY) { + $manifest.RegistryKeys += [ordered]@{ + Kind = 'CONFLICT'; Path = $env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_REGISTRY + Owned = $false; Token = $null + } + } + $manifest.Users = @($provisionalUserRecord) + if ($env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_USER) { + $manifest.Users += [ordered]@{ + Name = $env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_USER + Sid = $env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_USER_SID + Owned = $false + } + } + $manifest.Profiles = @() + $manifest.InstallAttempted = $true + if ($PublishCommittedReceipt) { $manifest.MsiTransactionState = 'COMMITTED' } + if ($env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_PROFILE_SID) { + $manifest.Profiles += [ordered]@{ + Sid = $env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_PROFILE_SID + LocalPath = $env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_PROFILE_PATH + Owned = $false + } + } + Write-FixtureOwnershipManifest $manifest + + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = (Get-Process -Id $PID -ErrorAction Stop).Path + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.UserName = $userName + $startInfo.Domain = $env:COMPUTERNAME + $startInfo.Password = $password + $startInfo.LoadUserProfile = $true + $startInfo.WorkingDirectory = $env:SystemRoot + foreach ($argument in @('-NoLogo','-NoProfile','-NonInteractive','-Command','exit 0')) { + $startInfo.ArgumentList.Add($argument) + } + $profileProcess = [Diagnostics.Process]::new() + $profileProcess.StartInfo = $startInfo + $profileProcessStarted = $false + try { + $profileProcessStarted = $profileProcess.Start() + if (!$profileProcessStarted -or !$profileProcess.WaitForExit(30000) -or + $profileProcess.ExitCode -ne 0) { + throw 'fixture owned profile creation failed' + } + } finally { + if ($profileProcessStarted -and !$profileProcess.HasExited) { + try { $profileProcess.Kill($true) } catch {} + } + $profileProcess.Dispose() + } + $profiles = @() + $profileLookupStopwatch = [Diagnostics.Stopwatch]::StartNew() + do { + $profiles = @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop | Where-Object { + $_.SID -ceq $userSid + }) + if ($profiles.Count -eq 1) { break } + Start-Sleep -Milliseconds 250 + } while ($profileLookupStopwatch.ElapsedMilliseconds -lt 10000) + if ($profiles.Count -ne 1) { throw 'fixture owned profile was not created' } + $canonicalProfilePath = (Resolve-Path -LiteralPath ([string]$profiles[0].LocalPath) ` + -ErrorAction Stop).ProviderPath.TrimEnd('\') + $manifest = [IO.File]::ReadAllText($OwnershipManifest, [Text.Encoding]::UTF8) | + ConvertFrom-Json -ErrorAction Stop + $manifest.Profiles = @($manifest.Profiles) + @([ordered]@{ + Sid = $userSid + LocalPath = $canonicalProfilePath + Owned = $true + }) + Write-FixtureOwnershipManifest $manifest + $resourceState = [ordered]@{ + OwnedRoot = $ownedRoot + InstallRoot = $installRoot + Executable = $executable + ShortcutFolder = $shortcutFolder + Shortcut = $shortcut + SmokeDirectory = $smokeDirectory + RegistryPath = $registryPath + RegistryRoot = Split-Path -Parent $registryPath + UserName = $userName + UserSid = $userSid + ProfilePath = $canonicalProfilePath + ManifestPath = $OwnershipManifest + RunId = [string]$manifest.RunId + Token = $token + } + $resourceState | ConvertTo-Json -Compress | Set-Content -LiteralPath ` + (Join-Path $stateDirectory 'resources.json') -Encoding ASCII +} + +function New-ByteIdenticalOwnedFileFixture { + Initialize-FixtureDirectoryIdentity + $manifest = [IO.File]::ReadAllText($OwnershipManifest, [Text.Encoding]::UTF8) | + ConvertFrom-Json -ErrorAction Stop + $root = Join-Path $stateDirectory 'byte-identical-file-root' + $executable = Join-Path $root 'owned-file.exe' + [void](New-Item -ItemType Directory -Path $root -ErrorAction Stop) + [IO.File]::WriteAllText($executable, 'owned-executable', [Text.Encoding]::ASCII) + $manifest.BaselineClean = $false + $manifest.InstallAttempted = $false + $manifest.MsiTransactionState = 'NONE' + $manifest.Directories = @() + $manifest.Files = @([ordered]@{ + Kind = 'FIXTURE_FILE'; Path = $executable; Owned = $true; Token = $null + Identity = (Get-FixtureFileIdentity $executable) + EntryIdentity = (Get-FixtureEntryIdentity $executable $false) + Provisional = $false + }) + $manifest.RegistryKeys = @() + $manifest.RegistryValues = @() + $manifest.Users = @() + $manifest.Profiles = @() + Write-FixtureOwnershipManifest $manifest + [ordered]@{ + Executable = $executable + ManifestPath = $OwnershipManifest + RunId = [string]$manifest.RunId + ByteIdenticalReplacement = $true + } | ConvertTo-Json -Compress | Set-Content -LiteralPath ` + (Join-Path $stateDirectory 'resources.json') -Encoding ASCII +} + +function New-SmokeCheckpointFixtureResources( + [ValidateSet('BEFORE_PROMOTION','AFTER_PROMOTION','AFTER_ARTIFACTS')] + [string]$Checkpoint +) { + Initialize-FixtureDirectoryIdentity + $manifest = [IO.File]::ReadAllText($OwnershipManifest, [Text.Encoding]::UTF8) | + ConvertFrom-Json -ErrorAction Stop + if (!$manifest.Fixture -or $manifest.SchemaVersion -ne 3 -or + [string]$manifest.InstallerEntryIdentity -notmatch '^[a-f0-9]{24}$' -or + [string]$manifest.InstallerSha256 -notmatch '^[a-f0-9]{64}$' -or + [string]$manifest.InstallerProductCode -notmatch + '^\{[A-F0-9]{8}(?:-[A-F0-9]{4}){3}-[A-F0-9]{12}\}$' -or + $manifest.State -cne 'ACTIVE') { + throw 'smoke checkpoint manifest was not initialized' + } + $token = [Guid]::NewGuid().ToString('N') + $userName = $env:PROPR_SUPERVISOR_FIXTURE_OWNED_USER + $passwordText = $env:PROPR_SUPERVISOR_FIXTURE_OWNED_PASSWORD + if ($userName -notmatch '^prpr[a-f0-9]{8}$' -or !$passwordText -or + (Get-LocalUser -Name $userName -ErrorAction SilentlyContinue)) { + throw 'smoke checkpoint user baseline is invalid' + } + $password = ConvertTo-SecureString $passwordText -AsPlainText -Force + $userMarker = "prpr-own-$([Guid]::NewGuid().ToString('N'))" + $userRecord = [ordered]@{ + Name = $userName + Sid = $null + Owned = $true + Provisional = $true + OwnershipMarker = $userMarker + } + $manifest.Users = @($userRecord) + Write-FixtureOwnershipManifest $manifest + New-LocalUser -Name $userName -Password $password -Description $userMarker ` + -AccountNeverExpires -PasswordNeverExpires | Out-Null + $userSid = (Get-LocalUser -Name $userName -ErrorAction Stop).SID.Value + $userRecord.Sid = $userSid + $userRecord.Provisional = $false + + $smokeDirectory = Join-Path $stateDirectory 'smoke-data' + $smokeRecord = [ordered]@{ + Kind = 'SMOKE_DATA' + Path = $smokeDirectory + Owned = $true + Token = $token + Identity = $null + Provisional = $true + UserSid = $userSid + CreatorSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value + RootOwnerSid = 'S-1-5-32-544' + } + $manifest.Directories = @($smokeRecord) + $manifest.Files = @() + $manifest.RegistryKeys = @() + $manifest.RegistryValues = @() + $manifest.Users = @($userRecord) + $manifest.Profiles = @() + Write-FixtureOwnershipManifest $manifest + + $resourceState = [ordered]@{ + OwnedRoot = $smokeDirectory + InstallRoot = Join-Path $stateDirectory 'absent-install-root' + ShortcutFolder = Join-Path $stateDirectory 'absent-shortcut-folder' + Shortcut = Join-Path $stateDirectory 'absent-shortcut.lnk' + SmokeDirectory = $smokeDirectory + RegistryPath = "Registry::HKEY_LOCAL_MACHINE\Software\ProPRSupervisorFixture\$($manifest.RunId)\absent" + RegistryRoot = "Registry::HKEY_LOCAL_MACHINE\Software\ProPRSupervisorFixture\$($manifest.RunId)" + UserName = $userName + UserSid = $userSid + ProfilePath = '' + ManifestPath = $OwnershipManifest + RunId = [string]$manifest.RunId + Token = $token + } + $resourceState | ConvertTo-Json -Compress | Set-Content -LiteralPath ` + (Join-Path $stateDirectory 'resources.json') -Encoding ASCII + + [void](New-Item -ItemType Directory -Path $smokeDirectory -ErrorAction Stop) + Set-FixtureSmokeAcl $smokeDirectory $userSid + Write-FixtureOwnershipToken (Join-Path $smokeDirectory '.propr-installed-app-owner') $token + if ($Checkpoint -eq 'BEFORE_PROMOTION') { return } + + $smokeRecord.Identity = [ProPRFixtureDirectoryIdentity]::Read($smokeDirectory) + $smokeRecord.Provisional = $false + Write-FixtureOwnershipManifest $manifest + if ($Checkpoint -eq 'AFTER_PROMOTION') { return } + + New-FixtureSmokeArtifacts $smokeDirectory +} + +function Replace-FixtureOwnedResources { + $state = Get-Content -LiteralPath (Join-Path $stateDirectory 'resources.json') ` + -Raw -Encoding ASCII | ConvertFrom-Json -ErrorAction Stop + foreach ($directory in @($state.OwnedRoot, $state.ShortcutFolder)) { + [IO.File]::WriteAllText( + (Join-Path $directory '.propr-installed-app-owner'), + 'foreign-owner', + [Text.Encoding]::ASCII + ) + } + $installRootBackup = Join-Path $stateDirectory 'original-install-tree' + $shortcutBackup = Join-Path $stateDirectory 'original-shortcut.lnk' + Move-Item -LiteralPath $state.InstallRoot -Destination $installRootBackup -ErrorAction Stop + [void](New-Item -ItemType Directory -Path $state.InstallRoot -ErrorAction Stop) + [IO.File]::WriteAllText( + (Join-Path $state.InstallRoot 'foreign.txt'), + 'foreign-install-tree', + [Text.Encoding]::ASCII + ) + Move-Item -LiteralPath $state.Shortcut -Destination $shortcutBackup -ErrorAction Stop + [IO.File]::WriteAllText($state.Shortcut, 'foreign-shortcut', [Text.Encoding]::ASCII) + Set-ItemProperty -LiteralPath $state.RegistryPath ` + -Name 'ProPRInstalledAppOwner' -Value 'foreign-owner' + $state | Add-Member -NotePropertyName InstallRootBackup -NotePropertyValue $installRootBackup + $state | Add-Member -NotePropertyName ShortcutBackup -NotePropertyValue $shortcutBackup + $state | ConvertTo-Json -Compress | Set-Content -LiteralPath ` + (Join-Path $stateDirectory 'resources.json') -Encoding ASCII +} + +function Replace-FixtureExecutable { + $state = Get-Content -LiteralPath (Join-Path $stateDirectory 'resources.json') ` + -Raw -Encoding ASCII | ConvertFrom-Json -ErrorAction Stop + $backup = Join-Path $stateDirectory 'original-executable.exe' + Move-Item -LiteralPath $state.Executable -Destination $backup -ErrorAction Stop + [IO.File]::WriteAllText($state.Executable, 'foreign-executable', [Text.Encoding]::ASCII) + $state | Add-Member -NotePropertyName ExecutableBackup -NotePropertyValue $backup + $state | ConvertTo-Json -Compress | Set-Content -LiteralPath ` + (Join-Path $stateDirectory 'resources.json') -Encoding ASCII +} + +function Replace-FixtureExecutableByteIdenticallyViaMove { + $state = Get-Content -LiteralPath (Join-Path $stateDirectory 'resources.json') ` + -Raw -Encoding ASCII | ConvertFrom-Json -ErrorAction Stop + $backup = Join-Path $stateDirectory 'original-byte-identical-executable.exe' + $replacement = Join-Path $stateDirectory 'foreign-byte-identical-executable.exe' + [IO.File]::Copy($state.Executable, $replacement, $false) + Move-Item -LiteralPath $state.Executable -Destination $backup -ErrorAction Stop + Move-Item -LiteralPath $replacement -Destination $state.Executable -ErrorAction Stop + $state | Add-Member -NotePropertyName ExecutableBackup -NotePropertyValue $backup + $state | Add-Member -NotePropertyName ByteIdenticalReplacement ` + -NotePropertyValue $true + $state | ConvertTo-Json -Compress | Set-Content -LiteralPath ` + (Join-Path $stateDirectory 'resources.json') -Encoding ASCII +} + +function Replace-FixtureShortcut { + $state = Get-Content -LiteralPath (Join-Path $stateDirectory 'resources.json') ` + -Raw -Encoding ASCII | ConvertFrom-Json -ErrorAction Stop + $backup = Join-Path $stateDirectory 'original-shortcut.lnk' + Move-Item -LiteralPath $state.Shortcut -Destination $backup -ErrorAction Stop + [IO.File]::WriteAllText($state.Shortcut, 'foreign-shortcut', [Text.Encoding]::ASCII) + $state | Add-Member -NotePropertyName ShortcutBackup -NotePropertyValue $backup + $state | ConvertTo-Json -Compress | Set-Content -LiteralPath ` + (Join-Path $stateDirectory 'resources.json') -Encoding ASCII +} + +function Replace-FixtureProfilePath { + $state = Get-Content -LiteralPath (Join-Path $stateDirectory 'resources.json') ` + -Raw -Encoding ASCII | ConvertFrom-Json -ErrorAction Stop + $mismatchedPath = Join-Path $stateDirectory 'mismatched-profile-path' + [void](New-Item -ItemType Directory -Path $mismatchedPath -ErrorAction Stop) + $canonicalMismatch = (Resolve-Path -LiteralPath $mismatchedPath -ErrorAction Stop).ProviderPath + $manifest = [IO.File]::ReadAllText($OwnershipManifest, [Text.Encoding]::UTF8) | + ConvertFrom-Json -ErrorAction Stop + $ownedProfile = @($manifest.Profiles | Where-Object { + $_.Owned -and [string]$_.Sid -ceq [string]$state.UserSid + }) + if ($ownedProfile.Count -ne 1) { + throw 'fixture durable profile ownership record is missing' + } + $ownedProfile[0].LocalPath = $canonicalMismatch + Write-FixtureOwnershipManifest $manifest + $state | Add-Member -NotePropertyName MismatchedProfilePath ` + -NotePropertyValue $canonicalMismatch + $state | ConvertTo-Json -Compress | Set-Content -LiteralPath ` + (Join-Path $stateDirectory 'resources.json') -Encoding ASCII +} + +function Add-FixtureForeignChild { + $state = Get-Content -LiteralPath (Join-Path $stateDirectory 'resources.json') ` + -Raw -Encoding ASCII | ConvertFrom-Json -ErrorAction Stop + [IO.File]::WriteAllText( + (Join-Path $state.InstallRoot 'foreign-in-place.txt'), + 'foreign-in-place', + [Text.Encoding]::ASCII + ) +} + +function Add-FixtureForeignSmokeDescendant { + $state = Get-Content -LiteralPath (Join-Path $stateDirectory 'resources.json') ` + -Raw -Encoding ASCII | ConvertFrom-Json -ErrorAction Stop + $foreignPath = Join-Path $state.SmokeDirectory 'foreign-in-place.txt' + [IO.File]::WriteAllText($foreignPath, 'foreign-smoke-in-place', [Text.Encoding]::ASCII) + $currentSid = [Security.Principal.WindowsIdentity]::GetCurrent().User + $acl = [Security.AccessControl.FileSecurity]::new() + $acl.SetAccessRuleProtection($true, $false) + $acl.SetOwner($currentSid) + $rule = [Security.AccessControl.FileSystemAccessRule]::new( + $currentSid, + [Security.AccessControl.FileSystemRights]::FullControl, + [Security.AccessControl.AccessControlType]::Allow + ) + [void]$acl.AddAccessRule($rule) + Set-Acl -LiteralPath $foreignPath -AclObject $acl -ErrorAction Stop + $state | Add-Member -NotePropertyName ForeignSmokePath -NotePropertyValue $foreignPath + $state | ConvertTo-Json -Compress | Set-Content -LiteralPath ` + (Join-Path $stateDirectory 'resources.json') -Encoding ASCII +} + +function Test-PrimaryFallbackForeignDescendants { + $installRoot = Join-Path $stateDirectory 'primary-install-root' + $shortcutFolder = Join-Path $stateDirectory 'primary-shortcut-folder' + [void](New-Item -ItemType Directory -Path $installRoot -ErrorAction Stop) + [void](New-Item -ItemType Directory -Path $shortcutFolder -ErrorAction Stop) + $installForeign = Join-Path $installRoot 'foreign-in-place.txt' + $shortcutForeign = Join-Path $shortcutFolder 'foreign-in-place.txt' + [IO.File]::WriteAllText($installForeign, 'foreign-install', [Text.Encoding]::ASCII) + [IO.File]::WriteAllText($shortcutForeign, 'foreign-shortcut', [Text.Encoding]::ASCII) + foreach ($directory in @($installRoot, $shortcutFolder)) { + $item = Get-Item -LiteralPath $directory -Force -ErrorAction Stop + if (!$item.PSIsContainer -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'primary fallback fixture directory is invalid' + } + if (@(Get-ChildItem -LiteralPath $directory -Force -ErrorAction Stop).Count -eq 0) { + Remove-Item -LiteralPath $directory -Force -ErrorAction Stop + throw 'primary fallback fixture did not contain a foreign descendant' + } + if (!(Test-Path -LiteralPath $directory -PathType Container)) { + throw 'primary fallback removed a nonempty owned directory' + } + } + [ordered]@{ + InstallForeign = $installForeign + ShortcutForeign = $shortcutForeign + } | ConvertTo-Json -Compress | Set-Content -LiteralPath ` + (Join-Path $stateDirectory 'primary-fallback.json') -Encoding ASCII +} + +function Start-FixtureDescendant { + $hostPath = (Get-Process -Id $PID -ErrorAction Stop).Path + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $hostPath + $startInfo.UseShellExecute = $false + foreach ($argument in @( + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-Command', + 'Start-Sleep -Seconds 300' + )) { + $startInfo.ArgumentList.Add($argument) + } + $process = [Diagnostics.Process]::new() + $process.StartInfo = $startInfo + if (!$process.Start()) { throw 'fixture descendant did not start' } + return $process +} + +$ownershipReady = [Threading.EventWaitHandle]::OpenExisting($OwnershipReadyEvent) +try { + if (!$ownershipReady.WaitOne(5000)) { throw 'fixture ownership was not established' } +} finally { + $ownershipReady.Dispose() +} + +$descendant = Start-FixtureDescendant +$state = [ordered]@{ WorkerPid = $PID; DescendantPid = $descendant.Id } +$processStatePath = Join-Path $stateDirectory 'processes.json' +$processStateTemporaryPath = "$processStatePath.$PID.new" +$processStateBytes = [Text.Encoding]::ASCII.GetBytes(($state | ConvertTo-Json -Compress)) +$processStateStream = [IO.FileStream]::new( + $processStateTemporaryPath, + [IO.FileMode]::CreateNew, + [IO.FileAccess]::Write, + [IO.FileShare]::Read, + 4096, + [IO.FileOptions]::WriteThrough +) +try { + $processStateStream.Write($processStateBytes, 0, $processStateBytes.Length) + $processStateStream.Flush($true) +} finally { + $processStateStream.Dispose() +} +[IO.File]::Move($processStateTemporaryPath, $processStatePath) + +switch ($scenario) { + 'NO_MARKER' { + Start-Sleep -Seconds 300 + } + 'NO_MARKER_WINDOWS_POWERSHELL' { + Start-Sleep -Seconds 300 + } + 'VALID_THEN_DEADLINE' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(10).Ticks) + Start-Sleep -Milliseconds 500 + Write-FixtureMarker ('{0}|VALIDATION|INSTALL_TREE_SCAN|BEGIN' -f [DateTime]::UtcNow.AddMilliseconds(2500).Ticks) + Start-Sleep -Seconds 300 + } + 'MALFORMED_MARKER' { + Write-FixtureMarker 'not-a-watchdog-record' + Start-Sleep -Seconds 300 + } + 'TORN_MARKER' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS' -f [DateTime]::UtcNow.AddSeconds(10).Ticks) + Start-Sleep -Seconds 300 + } + 'STALE_MARKER' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(-1).Ticks) + Start-Sleep -Seconds 300 + } + 'INACCESSIBLE_MARKER' { + $record = '{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(10).Ticks + $bytes = [Text.Encoding]::ASCII.GetBytes($record) + $stream = [IO.FileStream]::new( + $WatchdogMarker, + [IO.FileMode]::CreateNew, + [IO.FileAccess]::ReadWrite, + [IO.FileShare]::None, + 4096, + [IO.FileOptions]::WriteThrough + ) + try { + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + Start-Sleep -Seconds 300 + } finally { + $stream.Dispose() + } + } + 'CANCELLATION' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(10).Ticks) + Start-Sleep -Milliseconds 300 + Write-FixtureMarker ('{0}|VALIDATION|INSTALL_TREE_SCAN|BEGIN' -f ` + [DateTime]::UtcNow.AddSeconds(10).Ticks) + Start-Sleep -Seconds 300 + } + 'DURING_MSI' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + $manifest = [IO.File]::ReadAllText($OwnershipManifest, [Text.Encoding]::UTF8) | + ConvertFrom-Json -ErrorAction Stop + $manifest.BaselineClean = $true + $manifest.InstallAttempted = $true + $manifest.MsiTransactionState = 'PENDING' + Write-FixtureOwnershipManifest $manifest + Write-FixtureMarker ('{0}|INSTALL|MSI_INSTALL|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + Write-FixtureCriticalGate 'DURING_MSI' + Start-Sleep -Milliseconds 750 + $manifest.Directories = @() + $manifest.Files = @() + $manifest.RegistryKeys = @() + $manifest.RegistryValues = @() + $manifest.MsiTransactionState = 'ROLLED_BACK_CLEAN' + Write-FixtureOwnershipManifest $manifest + Write-FixtureMarker ('{0}|INSTALL|OWNERSHIP_CAPTURE|COMPLETE' -f ` + [DateTime]::UtcNow.AddSeconds(60).Ticks) + Start-Sleep -Seconds 300 + } + 'DURING_OWNERSHIP_CAPTURE' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + Initialize-FixtureDirectoryIdentity + $manifest = [IO.File]::ReadAllText($OwnershipManifest, [Text.Encoding]::UTF8) | + ConvertFrom-Json -ErrorAction Stop + $manifest.BaselineClean = $true + $manifest.InstallAttempted = $true + $manifest.MsiTransactionState = 'PENDING' + Write-FixtureOwnershipManifest $manifest + Write-FixtureMarker ('{0}|INSTALL|OWNERSHIP_CAPTURE|BEGIN' -f ` + [DateTime]::UtcNow.AddSeconds(60).Ticks) + Write-FixtureCriticalGate 'DURING_OWNERSHIP_CAPTURE' + Start-Sleep -Milliseconds 750 + New-OwnedFixtureResources -PublishCommittedReceipt $false + $manifest = [IO.File]::ReadAllText($OwnershipManifest, [Text.Encoding]::UTF8) | + ConvertFrom-Json -ErrorAction Stop + $manifest.MsiTransactionState = 'COMMITTED' + Write-FixtureOwnershipManifest $manifest + Write-FixtureMarker ('{0}|INSTALL|OWNERSHIP_CAPTURE|COMPLETE' -f ` + [DateTime]::UtcNow.AddSeconds(60).Ticks) + Start-Sleep -Seconds 300 + } + 'NEGATIVE_EXIT' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(10).Ticks) + Start-Sleep -Milliseconds 500 + exit -1 + } + 'OWNED_RESOURCES_THEN_DEADLINE' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + New-OwnedFixtureResources + Write-FixtureMarker ('{0}|CLEANUP|SMOKE_DATA_REMOVE|BEGIN' -f ` + [DateTime]::UtcNow.AddMilliseconds(500).Ticks) + Start-Sleep -Seconds 300 + } + 'OWNED_RESOURCES_FOR_INTERRUPTION' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + New-OwnedFixtureResources + Write-FixtureMarker ('{0}|CLEANUP|SMOKE_DATA_REMOVE|BEGIN' -f ` + [DateTime]::UtcNow.AddSeconds(60).Ticks) + Start-Sleep -Seconds 300 + } + 'SMOKE_BEFORE_PROMOTION_THEN_DEADLINE' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + New-SmokeCheckpointFixtureResources 'BEFORE_PROMOTION' + Write-FixtureMarker ('{0}|USER_SETUP|SMOKE_DATA_CREATE|BEGIN' -f ` + [DateTime]::UtcNow.AddMilliseconds(500).Ticks) + Start-Sleep -Seconds 300 + } + 'SMOKE_AFTER_PROMOTION_THEN_DEADLINE' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + New-SmokeCheckpointFixtureResources 'AFTER_PROMOTION' + Write-FixtureMarker ('{0}|USER_SETUP|SMOKE_DATA_CREATE|COMPLETE' -f ` + [DateTime]::UtcNow.AddMilliseconds(500).Ticks) + Start-Sleep -Seconds 300 + } + 'SMOKE_AFTER_ARTIFACTS_THEN_DEADLINE' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + New-SmokeCheckpointFixtureResources 'AFTER_ARTIFACTS' + Write-FixtureMarker ('{0}|APP_EXIT|EVIDENCE_INSPECTION|BEGIN' -f ` + [DateTime]::UtcNow.AddMilliseconds(500).Ticks) + Start-Sleep -Seconds 300 + } + 'SMOKE_FOREIGN_DESCENDANT_THEN_DEADLINE' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + New-SmokeCheckpointFixtureResources 'AFTER_ARTIFACTS' + Add-FixtureForeignSmokeDescendant + Write-FixtureMarker ('{0}|CLEANUP|SMOKE_DATA_REMOVE|BEGIN' -f ` + [DateTime]::UtcNow.AddMilliseconds(500).Ticks) + Start-Sleep -Seconds 300 + } + 'SMOKE_TOKEN_MISMATCH_THEN_DEADLINE' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + New-SmokeCheckpointFixtureResources 'BEFORE_PROMOTION' + $owned = Get-Content -LiteralPath (Join-Path $stateDirectory 'resources.json') ` + -Raw -Encoding ASCII | ConvertFrom-Json -ErrorAction Stop + [IO.File]::WriteAllText( + (Join-Path $owned.SmokeDirectory '.propr-installed-app-owner'), + 'foreign-owner', + [Text.Encoding]::ASCII + ) + Write-FixtureMarker ('{0}|USER_SETUP|SMOKE_DATA_CREATE|BEGIN' -f ` + [DateTime]::UtcNow.AddMilliseconds(500).Ticks) + Start-Sleep -Seconds 300 + } + 'PRIMARY_FALLBACK_FOREIGN_DESCENDANTS' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + Test-PrimaryFallbackForeignDescendants + Write-FixtureMarker ('{0}|CLEANUP|SHORTCUT_FALLBACK|COMPLETE' -f ` + [DateTime]::UtcNow.AddSeconds(60).Ticks) + } + 'OWNED_RESOURCES_REPLACED_THEN_DEADLINE' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + New-OwnedFixtureResources + Replace-FixtureOwnedResources + Write-FixtureMarker ('{0}|CLEANUP|SMOKE_DATA_REMOVE|BEGIN' -f ` + [DateTime]::UtcNow.AddMilliseconds(500).Ticks) + Start-Sleep -Seconds 300 + } + 'OWNED_EXECUTABLE_REPLACED_THEN_DEADLINE' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + New-OwnedFixtureResources + Replace-FixtureExecutable + Write-FixtureMarker ('{0}|CLEANUP|SMOKE_DATA_REMOVE|BEGIN' -f ` + [DateTime]::UtcNow.AddMilliseconds(500).Ticks) + Start-Sleep -Seconds 300 + } + 'OWNED_EXECUTABLE_BYTE_IDENTICAL_REPLACED_THEN_DEADLINE' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + New-ByteIdenticalOwnedFileFixture + Replace-FixtureExecutableByteIdenticallyViaMove + Write-FixtureMarker ('{0}|CLEANUP|SMOKE_DATA_REMOVE|BEGIN' -f ` + [DateTime]::UtcNow.AddMilliseconds(500).Ticks) + Start-Sleep -Seconds 300 + } + 'OWNED_SHORTCUT_REPLACED_THEN_DEADLINE' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + New-OwnedFixtureResources + Replace-FixtureShortcut + Write-FixtureMarker ('{0}|CLEANUP|SMOKE_DATA_REMOVE|BEGIN' -f ` + [DateTime]::UtcNow.AddMilliseconds(500).Ticks) + Start-Sleep -Seconds 300 + } + 'OWNED_PROFILE_PATH_MISMATCH_THEN_DEADLINE' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + New-OwnedFixtureResources + Replace-FixtureProfilePath + Write-FixtureMarker ('{0}|CLEANUP|PROFILE_REMOVE|BEGIN' -f ` + [DateTime]::UtcNow.AddMilliseconds(500).Ticks) + Start-Sleep -Seconds 300 + } + 'OWNED_RESOURCES_FOREIGN_CHILD_THEN_DEADLINE' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + New-OwnedFixtureResources + Add-FixtureForeignChild + Write-FixtureMarker ('{0}|CLEANUP|SMOKE_DATA_REMOVE|BEGIN' -f ` + [DateTime]::UtcNow.AddMilliseconds(500).Ticks) + Start-Sleep -Seconds 300 + } + 'OWNED_RESOURCES_NORMAL_SUCCESS' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + New-OwnedFixtureResources + Write-FixtureMarker ('{0}|CLEANUP|SMOKE_DATA_REMOVE|COMPLETE' -f ` + [DateTime]::UtcNow.AddSeconds(60).Ticks) + } +} + +$descendant.Dispose() diff --git a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 new file mode 100644 index 000000000..e76a10ecb --- /dev/null +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 @@ -0,0 +1,2393 @@ +param( + [Parameter(Mandatory=$true)][ValidateSet('x64','arm64')][string]$Architecture +) + +$ErrorActionPreference = 'Stop' +$supervisorPath = Join-Path $PSScriptRoot 'run-installed-windows-app-harness.ps1' +$workflowCleanupPath = Join-Path $PSScriptRoot 'run-installed-windows-app-workflow-cleanup.ps1' +$fixtureWorkerPath = Join-Path $PSScriptRoot 'test-installed-windows-app-supervisor-fixture.ps1' +$hostPath = (Get-Process -Id $PID -ErrorAction Stop).Path +$testRoot = Join-Path ([IO.Path]::GetTempPath()) ` + "propr-supervisor-tests-$([Guid]::NewGuid().ToString('N'))" +$dummyInstaller = Join-Path $testRoot 'fixture.msi' +$secretNeedle = 'C:\Users\fixture-user\token=fixture-credential' +$ownedFixtureUserName = "prpr$([Guid]::NewGuid().ToString('N').Substring(0,8))" +$ownedFixturePassword = "P!$([Guid]::NewGuid().ToString('N'))x7" +$conflictingFixtureUserName = $null +$conflictingFixtureUserSid = $null +$conflictingFixtureProfileSid = $null +$conflictingFixtureProfilePath = $null +$conflictingFixtureDirectories = $null +$conflictingFixtureShortcut = $null +$conflictingFixtureRegistryPath = $null +$dummyInstallerProductCode = ('{' + [Guid]::NewGuid().ToString().ToUpperInvariant() + '}') +$dummyInstallerEntryIdentity = $null +$dummyInstallerSha256 = $null + +function Assert-True([bool]$Condition, [string]$Message) { + if (!$Condition) { throw $Message } +} + +function Assert-Contains([string]$Text, [string]$Expected, [string]$Message) { + Assert-True ($Text.Contains($Expected, [StringComparison]::Ordinal)) $Message +} + +function Assert-NotContains([string]$Text, [string]$Forbidden, [string]$Message) { + Assert-True (!$Text.Contains($Forbidden, [StringComparison]::OrdinalIgnoreCase)) $Message +} + +function Test-WorkflowCleanupBodyParserRegression { + $cleanupBodyPath = Join-Path $PSScriptRoot ` + 'run-installed-windows-app-workflow-cleanup-body.ps1' + $tokens = $null + $parseErrors = $null + [void][System.Management.Automation.Language.Parser]::ParseFile( + $cleanupBodyPath, + [ref]$tokens, + [ref]$parseErrors + ) + Assert-True ($parseErrors.Count -eq 0) ` + 'workflow cleanup production body failed whole-file parser regression' +} + +function New-StateDirectory([string]$Name) { + $path = Join-Path $testRoot $Name + [void](New-Item -ItemType Directory -Path $path -ErrorAction Stop) + return $path +} + +function Write-TestOwnershipManifest([string]$Path, $Manifest) { + $temporaryPath = "$Path.test.new" + $bytes = [Text.Encoding]::UTF8.GetBytes( + ($Manifest | ConvertTo-Json -Depth 6 -Compress)) + $stream = [IO.FileStream]::new( + $temporaryPath, + [IO.FileMode]::Create, + [IO.FileAccess]::Write, + [IO.FileShare]::None, + 4096, + [IO.FileOptions]::WriteThrough + ) + try { + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + } finally { + $stream.Dispose() + } + [IO.File]::Move($temporaryPath, $Path, $true) +} + +function Initialize-TestInstaller { + $installerCom = $null + $database = $null + $view = $null + try { + $installerCom = New-Object -ComObject WindowsInstaller.Installer + $database = $installerCom.OpenDatabase($dummyInstaller, 3) + $view = $database.OpenView( + 'CREATE TABLE `Property` (`Property` CHAR(72) NOT NULL, ' + + '`Value` CHAR(0) LOCALIZABLE PRIMARY KEY `Property`)') + $view.Execute() + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($view) + $view = $null + $view = $database.OpenView( + "INSERT INTO ``Property`` (``Property``, ``Value``) VALUES ('ProductCode', '$dummyInstallerProductCode')") + $view.Execute() + $database.Commit() + } finally { + foreach ($resource in @($view, $database, $installerCom)) { + if ($null -ne $resource -and [Runtime.InteropServices.Marshal]::IsComObject($resource)) { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($resource) + } + } + } + + if (-not ('ProPRSupervisorInstallerIdentity' -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; +public static class ProPRSupervisorInstallerIdentity +{ + [StructLayout(LayoutKind.Sequential)] + private struct BY_HANDLE_FILE_INFORMATION + { + public uint FileAttributes; + public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime; + public uint VolumeSerialNumber; + public uint FileSizeHigh; + public uint FileSizeLow; + public uint NumberOfLinks; + public uint FileIndexHigh; + public uint FileIndexLow; + } + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFile(string path, uint access, uint share, + IntPtr security, uint creation, uint flags, IntPtr template); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetFileInformationByHandle( + SafeFileHandle handle, out BY_HANDLE_FILE_INFORMATION information); + public static string Read(string path) + { + using (SafeFileHandle handle = CreateFile( + path, 0x80, 0x7, IntPtr.Zero, 3, 0x00200000, IntPtr.Zero)) + { + if (handle == null || handle.IsInvalid) + throw new Win32Exception(Marshal.GetLastWin32Error()); + BY_HANDLE_FILE_INFORMATION information; + if (!GetFileInformationByHandle(handle, out information)) + throw new Win32Exception(Marshal.GetLastWin32Error()); + return string.Format("{0:x8}{1:x8}{2:x8}", information.VolumeSerialNumber, + information.FileIndexHigh, information.FileIndexLow); + } + } +} +'@ + } + $script:dummyInstallerEntryIdentity = + [ProPRSupervisorInstallerIdentity]::Read($dummyInstaller) + $script:dummyInstallerSha256 = + (Get-FileHash -LiteralPath $dummyInstaller -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() +} + +function New-SupervisorStartInfo( + [string]$Scenario, + [string]$StateDirectory, + [string]$CancellationEventName, + [bool]$UseProductionWorker, + [string]$WorkflowManifest = '', + [string]$ExpectedRunId = '', + [bool]$InjectTerminationFailure = $false +) { + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $hostPath + $startInfo.UseShellExecute = $false + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + foreach ($argument in @( + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-File', $supervisorPath, + '-Installer', $dummyInstaller, + '-Architecture', $Architecture, + '-BootstrapTimeoutMilliseconds', '10000', + '-WatchdogPollMilliseconds', '25', + '-WatchdogTerminationMilliseconds', '3000', + '-PostTerminationCleanupMilliseconds', '30000', + '-MarkerReadTimeoutMilliseconds', '200' + )) { + $startInfo.ArgumentList.Add([string]$argument) + } + if (!$UseProductionWorker) { + $startInfo.ArgumentList.Add('-WorkerPath') + $startInfo.ArgumentList.Add($fixtureWorkerPath) + $startInfo.ArgumentList.Add('-FixtureCleanupRoot') + $startInfo.ArgumentList.Add($StateDirectory) + $startInfo.Environment['PROPR_SUPERVISOR_FIXTURE_SCENARIO'] = $Scenario + $startInfo.Environment['PROPR_SUPERVISOR_FIXTURE_STATE_DIRECTORY'] = $StateDirectory + $startInfo.Environment['PROPR_SUPERVISOR_FIXTURE_SECRET'] = $secretNeedle + $startInfo.Environment['PROPR_SUPERVISOR_FIXTURE_OWNED_USER'] = $ownedFixtureUserName + $startInfo.Environment['PROPR_SUPERVISOR_FIXTURE_OWNED_PASSWORD'] = $ownedFixturePassword + if ($conflictingFixtureUserName) { + $startInfo.Environment['PROPR_SUPERVISOR_FIXTURE_CONFLICT_USER'] = + $conflictingFixtureUserName + $startInfo.Environment['PROPR_SUPERVISOR_FIXTURE_CONFLICT_USER_SID'] = + $conflictingFixtureUserSid + $startInfo.Environment['PROPR_SUPERVISOR_FIXTURE_CONFLICT_PROFILE_SID'] = + $conflictingFixtureProfileSid + $startInfo.Environment['PROPR_SUPERVISOR_FIXTURE_CONFLICT_PROFILE_PATH'] = + $conflictingFixtureProfilePath + $startInfo.Environment['PROPR_SUPERVISOR_FIXTURE_CONFLICT_DIRECTORIES'] = + $conflictingFixtureDirectories + $startInfo.Environment['PROPR_SUPERVISOR_FIXTURE_CONFLICT_SHORTCUT'] = + $conflictingFixtureShortcut + $startInfo.Environment['PROPR_SUPERVISOR_FIXTURE_CONFLICT_REGISTRY'] = + $conflictingFixtureRegistryPath + } + } + if ($InjectTerminationFailure) { + $startInfo.ArgumentList.Add('-InjectTerminationFailure') + } + if ($CancellationEventName) { + $startInfo.ArgumentList.Add('-CancellationEventName') + $startInfo.ArgumentList.Add($CancellationEventName) + } + if ($WorkflowManifest) { + $startInfo.ArgumentList.Add('-OwnershipManifest') + $startInfo.ArgumentList.Add($WorkflowManifest) + $startInfo.ArgumentList.Add('-ExpectedRunId') + $startInfo.ArgumentList.Add($ExpectedRunId) + } + return $startInfo +} + +function Read-FixtureProcessState([string]$StateDirectory) { + $statePath = Join-Path $StateDirectory 'processes.json' + $stopwatch = [Diagnostics.Stopwatch]::StartNew() + while (!(Test-Path -LiteralPath $statePath -PathType Leaf)) { + if ($stopwatch.ElapsedMilliseconds -ge 15000) { + throw 'fixture did not publish process state' + } + Start-Sleep -Milliseconds 25 + } + return Get-Content -LiteralPath $statePath -Raw -Encoding ASCII | ConvertFrom-Json +} + +function Read-FixtureResourceState([string]$StateDirectory) { + $statePath = Join-Path $StateDirectory 'resources.json' + $stopwatch = [Diagnostics.Stopwatch]::StartNew() + while (!(Test-Path -LiteralPath $statePath -PathType Leaf)) { + if ($stopwatch.ElapsedMilliseconds -ge 45000) { + throw 'fixture did not publish owned resource state' + } + Start-Sleep -Milliseconds 25 + } + return Get-Content -LiteralPath $statePath -Raw -Encoding ASCII | ConvertFrom-Json +} + +function Assert-ProcessTreeGone($State) { + $stopwatch = [Diagnostics.Stopwatch]::StartNew() + do { + $worker = Get-Process -Id ([int]$State.WorkerPid) -ErrorAction SilentlyContinue + $descendant = Get-Process -Id ([int]$State.DescendantPid) -ErrorAction SilentlyContinue + if ($null -eq $worker -and $null -eq $descendant) { return } + Start-Sleep -Milliseconds 25 + } while ($stopwatch.ElapsedMilliseconds -lt 3000) + throw 'owned worker process tree survived supervisor completion' +} + +function Get-SanitizedSupervisorMarkerDiagnostic($Result) { + $bootstrapTimedOutPresent = [regex]::IsMatch( + [string]$Result.Output, + '(?m)^PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:BOOTSTRAP:TIMED_OUT\r?$' + ) + $lastValidNonePresent = [regex]::IsMatch( + [string]$Result.Output, + '(?m)^PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:LAST_VALID:NONE\r?$' + ) + $postTerminationMatch = [regex]::Match( + [string]$Result.Output, + '(?m)^PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:' + + 'POST_TERMINATION_CLEANUP:(COMPLETE|FAILED|TIMED_OUT)\r?$' + ) + $postTerminationOutcome = if ($postTerminationMatch.Success) { + $postTerminationMatch.Groups[1].Value + } else { 'NONE' } + $workerTreeMatch = [regex]::Match( + [string]$Result.Output, + '(?m)^PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:FIXTURE_FINALIZATION:' + + 'WORKER_TREE_TERMINATION:(COMPLETE|FAILED)\r?$' + ) + $cleanupChildMatch = [regex]::Match( + [string]$Result.Output, + '(?m)^PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:FIXTURE_FINALIZATION:' + + 'CLEANUP_CHILD_EXIT:(0|20|21|OTHER)\r?$' + ) + $subphase = if ($workerTreeMatch.Success -and + $workerTreeMatch.Groups[1].Value -ceq 'FAILED') { + 'WORKER_TREE_TERMINATION' + } elseif ($cleanupChildMatch.Success) { + 'CLEANUP_CHILD_EXIT' + } else { 'NONE' } + $cleanupChildExit = if ($cleanupChildMatch.Success) { + $cleanupChildMatch.Groups[1].Value + } else { 'OTHER' } + $cleanupValidationPhaseMatch = [regex]::Match( + [string]$Result.Output, + '(?m)^PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:CLEANUP_VALIDATION_PHASE:' + + '(HANDSHAKE|FILE_AUTHORITY|UTF8_DECODE|JSON_PARSE|EXACT_KEY_SET|' + + 'BOOLEAN_TYPES|TRANSACTION_ENUM|SCHEMA_TYPE_STATE|RUN_ID_FORMAT|' + + 'INSTALLER_ENTRY_ID_FORMAT|INSTALLER_SHA256_FORMAT|INSTALLER_PRODUCT_CODE_FORMAT|' + + 'LIFETIME|RUN_ID|INSTALLER_PATH|FIXTURE_SCOPE|INITIAL_ACTIVE_MATCH|' + + 'INITIAL_INSTALLER_AUTHORITY_RECHECK|EMPTY_RECEIPT_WRITE)\r?$' + ) + $cleanupValidationPhase = if ($cleanupValidationPhaseMatch.Success) { + $cleanupValidationPhaseMatch.Groups[1].Value + } else { 'NONE' } + $signedExit = ([int]$Result.ExitCode).ToString( + [Globalization.CultureInfo]::InvariantCulture) + return ('SUPERVISOR_EXIT:{0}:BOOTSTRAP_TIMED_OUT:{1}:LAST_VALID_NONE:{2}:' + + 'POST_TERMINATION_CLEANUP:{3}:SUBPHASE:{4}:CLEANUP_CHILD_EXIT:{5}:' + + 'CLEANUP_VALIDATION_PHASE:{6}') -f ` + $signedExit, ([int]$bootstrapTimedOutPresent), ([int]$lastValidNonePresent), + $postTerminationOutcome, $subphase, $cleanupChildExit, $cleanupValidationPhase +} + +function Get-SanitizedCriticalCancellationDiagnostic($Result) { + $processExit = 0 + if (![int]::TryParse( + [string]$Result.ExitCode, + [Globalization.NumberStyles]::AllowLeadingSign, + [Globalization.CultureInfo]::InvariantCulture, + [ref]$processExit + )) { + $processExit = [int]::MinValue + } + + $msiTransaction = 'INVALID' + $postTerminationCleanup = 'INVALID' + $authorityState = 'INVALID' + $output = [string]$Result.Output + $outputByteLimit = 4096 + $outputLineLimit = 32 + $outputLineByteLimit = 192 + $protocolValid = [Text.Encoding]::UTF8.GetByteCount($output) -le $outputByteLimit + $lines = [Collections.Generic.List[string]]::new() + if ($protocolValid) { + $rawLines = @([regex]::Split($output, '\r?\n')) + $lineCount = $rawLines.Count + if ($lineCount -gt 0 -and $rawLines[$lineCount - 1] -ceq '') { + $lineCount-- + } + if ($lineCount -gt $outputLineLimit) { + $protocolValid = $false + } else { + for ($index = 0; $index -lt $lineCount; $index++) { + $line = [string]$rawLines[$index] + if ([string]::IsNullOrEmpty($line) -or + $line.IndexOf("`r", [StringComparison]::Ordinal) -ge 0 -or + [Text.Encoding]::ASCII.GetByteCount($line) -gt $outputLineByteLimit -or + [regex]::IsMatch($line, '[^\x20-\x7e]')) { + $protocolValid = $false + break + } + $lines.Add($line) + } + } + } + + if ($protocolValid) { + $msiEvents = [Collections.Generic.List[string]]::new() + $cleanupEvents = [Collections.Generic.List[string]]::new() + $authorityEvents = [Collections.Generic.List[string]]::new() + $msiPrefix = 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:MSI_TRANSACTION:' + $cleanupPrefix = + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:' + $lastValidPrefix = 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:LAST_VALID:' + $lastValidPattern = + '^PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:LAST_VALID:' + + '(INITIALIZATION|INSTALL|VALIDATION|USER_SETUP|APP_LAUNCH|APP_EXIT|UNINSTALL|CLEANUP):' + + '(PATHS|BASELINE|MSI_INSTALL|OWNERSHIP_CAPTURE|INSTALL_TREE_SCAN|' + + 'APPLICATION_IMAGE|PROTOCOL_ASSERTION|APP_PATH_ASSERTION|' + + 'HKCU_INSTALLED_ASSERTION|SHORTCUT_ASSERTION|USER_CREATE|USER_SID|' + + 'SMOKE_DATA_CREATE|SHORTCUT_PRESENT_PROBE|ALTERNATE_USER_START|' + + 'APPLICATION_WAIT|STREAM_DRAIN|EVIDENCE_INSPECTION|MSI_UNINSTALL|' + + 'INSTALL_TREE_ASSERTION|PROTOCOL_ABSENCE_ASSERTION|' + + 'APP_PATH_ABSENCE_ASSERTION|HKCU_INSTALLED_ABSENCE_ASSERTION|' + + 'SHORTCUT_FILE_ASSERTION|SHORTCUT_FOLDER_ASSERTION|' + + 'SHORTCUT_ABSENCE_PROBE|SMOKE_DATA_REMOVE|PROFILE_LOOKUP|' + + 'PROFILE_REMOVE|USER_LOOKUP|USER_REMOVE|INSTALL_ROOT_FALLBACK|' + + 'PROTOCOL_FALLBACK|APP_PATH_FALLBACK|HKCU_INSTALLED_FALLBACK|' + + 'SHORTCUT_FALLBACK):(BEGIN|COMPLETE|FAILED)$' + + foreach ($line in $lines) { + if ($line.StartsWith($msiPrefix, [StringComparison]::Ordinal)) { + $match = [regex]::Match( + $line, + '^PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:MSI_TRANSACTION:' + + '(GRACE|COMMITTED|ROLLED_BACK_CLEAN|UNPROVEN)$' + ) + if (!$match.Success) { $protocolValid = $false; break } + $msiEvents.Add($match.Groups[1].Value) + } elseif ($line.StartsWith($cleanupPrefix, [StringComparison]::Ordinal)) { + $match = [regex]::Match( + $line, + '^PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:' + + 'POST_TERMINATION_CLEANUP:(COMPLETE|FAILED|TIMED_OUT)$' + ) + if (!$match.Success) { $protocolValid = $false; break } + $cleanupEvents.Add($match.Groups[1].Value) + } elseif ($line.StartsWith($lastValidPrefix, [StringComparison]::Ordinal)) { + if ($line -ceq ($lastValidPrefix + 'NONE')) { + $authorityEvents.Add('NONE') + continue + } + $match = [regex]::Match($line, $lastValidPattern) + if (!$match.Success) { $protocolValid = $false; break } + if ($match.Groups[1].Value -ceq 'INSTALL' -and + $match.Groups[2].Value -ceq 'OWNERSHIP_CAPTURE') { + $authorityEvent = @(switch ($match.Groups[3].Value) { + 'BEGIN' { 'PROVISIONAL' } + 'COMPLETE' { 'NONPROVISIONAL' } + 'FAILED' { 'FAILED' } + }) + if ($authorityEvent.Count -ne 1 -or + $authorityEvent[0] -cnotin @('PROVISIONAL','NONPROVISIONAL','FAILED')) { + $protocolValid = $false + break + } + $authorityEvents.Add([string]$authorityEvent[0]) + } else { + $authorityEvents.Add('OTHER') + } + } + } + + if ($protocolValid) { + if ($msiEvents.Count -eq 0) { + $msiTransaction = 'NONE' + } elseif ($msiEvents.Count -eq 1 -and $msiEvents[0] -ceq 'GRACE') { + $msiTransaction = 'GRACE' + } elseif ($msiEvents.Count -eq 2 -and $msiEvents[0] -ceq 'GRACE' -and + $msiEvents[1] -cin @('COMMITTED','ROLLED_BACK_CLEAN','UNPROVEN')) { + $msiTransaction = $msiEvents[1] + } + if ($cleanupEvents.Count -eq 0) { + $postTerminationCleanup = 'NONE' + } elseif ($cleanupEvents.Count -eq 1) { + $postTerminationCleanup = $cleanupEvents[0] + } + if ($authorityEvents.Count -eq 0) { + $authorityState = 'ABSENT' + } elseif ($authorityEvents.Count -eq 1) { + $authorityState = $authorityEvents[0] + } + } + } + + $diagnostic = ('PROCESS_EXIT:{0}:MSI_TRANSACTION:{1}:' + + 'POST_TERMINATION_CLEANUP:{2}:AUTHORITY_STATE:{3}') -f ` + $processExit.ToString([Globalization.CultureInfo]::InvariantCulture), + $msiTransaction, $postTerminationCleanup, $authorityState + if ($diagnostic.IndexOf("`r", [StringComparison]::Ordinal) -ge 0 -or + $diagnostic.IndexOf("`n", [StringComparison]::Ordinal) -ge 0 -or + [Text.Encoding]::ASCII.GetByteCount($diagnostic) -gt 192) { + return ('PROCESS_EXIT:{0}:MSI_TRANSACTION:INVALID:' + + 'POST_TERMINATION_CLEANUP:INVALID:AUTHORITY_STATE:INVALID') -f ` + $processExit.ToString([Globalization.CultureInfo]::InvariantCulture) + } + return $diagnostic +} + +function Get-SanitizedWorkflowCleanupResultDiagnostic($Result) { + $processExit = 0 + if (![int]::TryParse( + [string]$Result.ExitCode, + [Globalization.NumberStyles]::AllowLeadingSign, + [Globalization.CultureInfo]::InvariantCulture, + [ref]$processExit + )) { + $processExit = [int]::MinValue + } + $reportedExitCode = 0 + if (![int]::TryParse( + [string]$Result.ReportedExitCode, + [Globalization.NumberStyles]::None, + [Globalization.CultureInfo]::InvariantCulture, + [ref]$reportedExitCode + ) -or $reportedExitCode -notin @(0,20,21,122,123,124,125)) { + $reportedExitCode = -1 + } + $resultName = if ([string]$Result.Result -cin @('COMPLETE','FAILED','TIMED_OUT')) { + [string]$Result.Result + } else { 'INVALID' } + $fixedStatuses = @( + 'CONTROLLER_FAILURE','TIMEOUT','TERMINATION_FAILURE', + 'ACTIVE_PROCESS_AFTER_ROOT_EXIT','EMPTY_OR_CLEANED', + 'MANIFEST_VALIDATION_FAILURE','OWNED_RESOURCE_CLEANUP_FAILURE', + 'PROCESS_FINALIZATION_TIMEOUT','PROCESS_FINALIZATION_FAILURE', + 'STREAM_DRAIN_TIMEOUT','CHILD_STDERR_LIMIT','CHILD_STDERR', + 'CHILD_STDOUT_LIMIT','CHILD_STDOUT','STREAM_DRAIN_FAILURE', + 'RESOURCE_FINALIZATION_FAILURE','AUTHORITY_FINALIZATION_FAILURE', + 'STARTUP_FAILURE' + ) + $controllerStatus = [string]$Result.ControllerStatus + if ($controllerStatus -cnotin $fixedStatuses -and + $controllerStatus -cnotmatch ( + '^CONTROLLER_(INITIALIZATION|PARAMETER_VALIDATION|PATH_VALIDATION|' + + 'PROCESS_START|PROCESS_WAIT|PROCESS_FINALIZATION|STREAM_FINALIZATION|' + + 'RESOURCE_FINALIZATION|AUTHORITY_FINALIZATION|RESULT_EMISSION)_' + + '(TYPE_LOAD|PARAMETERS|PATHS|START|WAIT|TERMINATE|DRAIN|DISPOSE|' + + 'AUTHORITY|EMIT)_(AUTHENTICATION|CLOSE|INVALID_ARGUMENT|INVALID_DATA|' + + 'INVALID_OPERATION|LIMIT|NOT_ENABLED|NOT_FOUND|OPEN|STOPPED|' + + 'PERMISSION|READ|BUSY|UNAVAILABLE|SECURITY|WRITE|UNCLASSIFIED)$')) { + $controllerStatus = 'INVALID' + } + $startupDiagnostic = '' + if ($controllerStatus -ceq 'STARTUP_FAILURE') { + $startupClass = [string]$Result.StartupClass + if ($startupClass -cnotin @('PARSER','PARAMETER_BINDING','TYPE_LOAD','OTHER')) { + $startupClass = 'INVALID' + } + + $startupProcessExit = 'INVALID' + $startupProcessExitCandidate = [string]$Result.StartupProcessExit + $parsedStartupProcessExit = 0 + if ($startupProcessExitCandidate -cmatch '^(?:0|-?[1-9][0-9]*)$' -and + [int]::TryParse( + $startupProcessExitCandidate, + [Globalization.NumberStyles]::AllowLeadingSign, + [Globalization.CultureInfo]::InvariantCulture, + [ref]$parsedStartupProcessExit + )) { + $startupProcessExit = + $parsedStartupProcessExit.ToString([Globalization.CultureInfo]::InvariantCulture) + } + + $startupLine = 'INVALID' + $startupLineCandidate = [string]$Result.StartupLine + $parsedStartupLine = 0 + if ($startupLineCandidate -cmatch '^[1-9][0-9]{0,5}$' -and + [int]::TryParse( + $startupLineCandidate, + [Globalization.NumberStyles]::None, + [Globalization.CultureInfo]::InvariantCulture, + [ref]$parsedStartupLine + ) -and $parsedStartupLine -le 999999) { + $startupLine = + $parsedStartupLine.ToString([Globalization.CultureInfo]::InvariantCulture) + } + + $startupDiagnostic = (':STARTUP_CLASS:{0}:STARTUP_PROCESS_EXIT:{1}:' + + 'STARTUP_LINE:{2}') -f $startupClass, $startupProcessExit, $startupLine + } + $diagnostic = ('EXIT_CODE:{0}:RESULT:{1}:CONTROLLER_STATUS:{2}:' + + 'REPORTED_EXIT_CODE:{3}{4}') -f ` + $processExit.ToString([Globalization.CultureInfo]::InvariantCulture), + $resultName, $controllerStatus, + $reportedExitCode.ToString([Globalization.CultureInfo]::InvariantCulture), + $startupDiagnostic + if ($diagnostic.IndexOf("`r", [StringComparison]::Ordinal) -ge 0 -or + $diagnostic.IndexOf("`n", [StringComparison]::Ordinal) -ge 0 -or + [Text.Encoding]::ASCII.GetByteCount($diagnostic) -gt 256) { + return ('EXIT_CODE:{0}:RESULT:INVALID:CONTROLLER_STATUS:INVALID:' + + 'REPORTED_EXIT_CODE:-1') -f ` + $processExit.ToString([Globalization.CultureInfo]::InvariantCulture) + } + return $diagnostic +} + +function Get-WorkflowCleanupControllerStatusMatch([string]$StatusLine) { + return [regex]::Match( + $StatusLine, + ('^PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:STATUS:([A-Z_]+):' + + 'EXIT_CODE:([0-9]+)(?::STARTUP_CLASS:' + + '(PARSER|PARAMETER_BINDING|TYPE_LOAD|OTHER):PROCESS_EXIT:(-?[0-9]+):' + + 'LINE:([0-9]+))?$') + ) +} + +function Assert-OwnedResourcesGone($Owned) { + foreach ($ownedPath in @( + $Owned.OwnedRoot, $Owned.InstallRoot, $Owned.ShortcutFolder, + $Owned.Shortcut, $Owned.SmokeDirectory + )) { + Assert-True (!(Test-Path -LiteralPath $ownedPath)) ` + 'external cleanup left a run-owned file-system resource behind' + } + Assert-True (!(Test-Path -LiteralPath $Owned.RegistryPath)) ` + 'external cleanup left a run-owned registry resource behind' + Assert-True (!(Test-Path -LiteralPath $Owned.RegistryRoot)) ` + 'external cleanup left the run-owned registry root behind' + Assert-True ($null -eq (Get-LocalUser -Name $Owned.UserName -ErrorAction SilentlyContinue)) ` + 'external cleanup left the run-owned local user behind' + $ownedProfiles = @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop | + Where-Object { $_.SID -ceq $Owned.UserSid }) + Assert-True ($ownedProfiles.Count -eq 0) ` + 'external cleanup left the run-owned profile behind' +} + +function Restore-ReplacedFixtureAuthority($Owned) { + [IO.File]::WriteAllText( + (Join-Path $Owned.OwnedRoot '.propr-installed-app-owner'), + [string]$Owned.Token, + [Text.Encoding]::ASCII + ) + if ($Owned.PSObject.Properties['InstallRootBackup']) { + Remove-Item -LiteralPath $Owned.InstallRoot -Recurse -Force -ErrorAction Stop + Move-Item -LiteralPath $Owned.InstallRootBackup -Destination $Owned.InstallRoot ` + -ErrorAction Stop + } elseif ($Owned.PSObject.Properties['ExecutableBackup']) { + Remove-Item -LiteralPath $Owned.Executable -Force -ErrorAction Stop + Move-Item -LiteralPath $Owned.ExecutableBackup -Destination $Owned.Executable ` + -ErrorAction Stop + } + [IO.File]::WriteAllText( + (Join-Path $Owned.ShortcutFolder '.propr-installed-app-owner'), + [string]$Owned.Token, + [Text.Encoding]::ASCII + ) + if ($Owned.PSObject.Properties['ShortcutBackup']) { + Remove-Item -LiteralPath $Owned.Shortcut -Force -ErrorAction Stop + Move-Item -LiteralPath $Owned.ShortcutBackup -Destination $Owned.Shortcut ` + -ErrorAction Stop + } + Set-ItemProperty -LiteralPath $Owned.RegistryPath ` + -Name 'ProPRInstalledAppOwner' -Value ([string]$Owned.Token) +} + +function Assert-ReplacedFixtureResourcesSurvive($Owned) { + Assert-True ((Get-Content -LiteralPath (Join-Path $Owned.InstallRoot 'foreign.txt') -Raw).Trim() ` + -ceq 'foreign-install-tree') ` + 'replacement install tree was removed or changed' + Assert-True ((Get-Content -LiteralPath $Owned.Shortcut -Raw).Trim() -ceq 'foreign-shortcut') ` + 'replacement shortcut was removed or changed' + Assert-True ((Get-ItemPropertyValue -LiteralPath $Owned.RegistryPath ` + -Name 'ProPRInstalledAppOwner') -ceq 'foreign-owner') ` + 'replacement registry authority was removed or changed' +} + +function Assert-ReplacedExecutableSurvives($Owned) { + $expected = if ($Owned.PSObject.Properties['ByteIdenticalReplacement']) { + 'owned-executable' + } else { 'foreign-executable' } + Assert-True ((Get-Content -LiteralPath $Owned.Executable -Raw).Trim() -ceq + $expected) 'replacement executable was removed or changed' +} + +function Assert-ReplacedShortcutSurvives($Owned) { + Assert-True ((Get-Content -LiteralPath $Owned.Shortcut -Raw).Trim() -ceq + 'foreign-shortcut') 'replacement shortcut was removed or changed' +} + +function Assert-MsiPreflightPreservedResources($Owned) { + foreach ($path in @( + $Owned.OwnedRoot, $Owned.InstallRoot, $Owned.ShortcutFolder, + $Owned.Shortcut, $Owned.SmokeDirectory, $Owned.RegistryPath + )) { + Assert-True (Test-Path -LiteralPath $path) ` + 'MSI file-system preflight failure mutated a run resource' + } + Assert-True ($null -ne (Get-LocalUser -Name $Owned.UserName -ErrorAction SilentlyContinue)) ` + 'MSI file-system preflight failure removed the run-owned user' +} + +function Get-SanitizedControllerStartupDiagnostic( + [string]$ErrorText, + [int]$ProcessExitCode +) { + $classification = if ($ErrorText -match + '(?im)\bParserError\b|\bMissingEndCurlyBrace\b|\bUnexpectedToken\b|\bParseException\b') { + 'PARSER' + } elseif ($ErrorText -match + '(?im)\bParameterBinding(?:Exception|ValidationException)?\b|cannot bind (?:argument|parameter)|parameter cannot be processed') { + 'PARAMETER_BINDING' + } elseif ($ErrorText -match + '(?im)\bAdd-Type\b|\bTypeNotFound\b|unable to find type|error CS[0-9]{4}') { + 'TYPE_LOAD' + } else { + 'OTHER' + } + $lineNumber = 0 + $lineMatch = [regex]::Match( + $ErrorText, + '(?im)^\s*at .+?:(\d+)\s+char:\d+\s*$' + ) + if (!$lineMatch.Success) { + $lineMatch = [regex]::Match($ErrorText, '(?im)\bline\s+(\d+)\b') + } + if ($lineMatch.Success) { + [void]([int]::TryParse( + $lineMatch.Groups[1].Value, + [Globalization.NumberStyles]::None, + [Globalization.CultureInfo]::InvariantCulture, + [ref]$lineNumber + )) + } + $signedExit = $ProcessExitCode.ToString([Globalization.CultureInfo]::InvariantCulture) + $numericLine = $lineNumber.ToString([Globalization.CultureInfo]::InvariantCulture) + return 'STARTUP_CLASS:{0}:PROCESS_EXIT:{1}:LINE:{2}' -f ` + $classification, $signedExit, $numericLine +} + +function Invoke-WorkflowCleanupController( + [string]$ManifestPath, + [string]$RunId, + [string]$FixtureRoot, + [object]$CleanupTimeoutMilliseconds = 30000, + [bool]$FixtureEarlyInitializationChild = $false, + [string]$StartupFailureClass = '' +) { + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $hostPath + $startInfo.UseShellExecute = $false + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + foreach ($argument in @( + '-NoLogo', '-NoProfile', '-NonInteractive', '-File', $workflowCleanupPath, + '-OwnershipManifest', $ManifestPath, + '-Installer', $dummyInstaller, + '-ExpectedRunId', $RunId, + '-CleanupTimeoutMilliseconds', [string]$CleanupTimeoutMilliseconds, + '-TerminationTimeoutMilliseconds', '3000' + )) { + $startInfo.ArgumentList.Add($argument) + } + if ($FixtureRoot) { + $startInfo.ArgumentList.Add('-FixtureRoot') + $startInfo.ArgumentList.Add($FixtureRoot) + } + if ($FixtureEarlyInitializationChild) { + $startInfo.ArgumentList.Add('-FixtureEarlyInitializationChild') + } + if ($StartupFailureClass) { + $startInfo.ArgumentList.Add('-StartupFailureClass') + $startInfo.ArgumentList.Add($StartupFailureClass) + } + $process = [Diagnostics.Process]::new() + $process.StartInfo = $startInfo + try { + if (!$process.Start()) { throw 'workflow cleanup fixture did not start' } + Assert-True ($process.WaitForExit(40000)) 'workflow cleanup fixture exceeded its bound' + $output = $process.StandardOutput.ReadToEnd() + $errorOutput = $process.StandardError.ReadToEnd() + $outputLines = @($output -split '\r?\n' | Where-Object { $_ }) + $lineCount = if ($outputLines.Count -ge 3) { '3+' } else { [string]$outputLines.Count } + $stderrCount = [Math]::Min(4096, $errorOutput.Length) + if ($output.Length -gt 512 -or $outputLines.Count -ne 2) { + $startupDiagnostic = Get-SanitizedControllerStartupDiagnostic ` + $errorOutput ([int]$process.ExitCode) + throw ('PROPR_WORKFLOW_CLEANUP_FIXTURE:PROTOCOL_MISMATCH:LINE_COUNT:{0}:STDERR_COUNT:{1}:{2}' -f ` + $lineCount, $stderrCount, $startupDiagnostic) + } + $resultMatch = [regex]::Match( + $outputLines[0], + '^PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:(COMPLETE|FAILED|TIMED_OUT)$' + ) + if (!$resultMatch.Success) { + $startupDiagnostic = Get-SanitizedControllerStartupDiagnostic ` + $errorOutput ([int]$process.ExitCode) + throw ('PROPR_WORKFLOW_CLEANUP_FIXTURE:PROTOCOL_MISMATCH:LINE_COUNT:{0}:STDERR_COUNT:{1}:{2}' -f ` + $lineCount, $stderrCount, $startupDiagnostic) + } + $resultName = $resultMatch.Groups[1].Value + $statusMatch = Get-WorkflowCleanupControllerStatusMatch $outputLines[1] + if (!$statusMatch.Success) { + $startupDiagnostic = Get-SanitizedControllerStartupDiagnostic ` + $errorOutput ([int]$process.ExitCode) + throw ('PROPR_WORKFLOW_CLEANUP_FIXTURE:PROTOCOL_MISMATCH:LINE_COUNT:{0}:STDERR_COUNT:{1}:{2}' -f ` + $lineCount, $stderrCount, $startupDiagnostic) + } + $controllerStatus = $statusMatch.Groups[1].Value + $reportedExitCode = [int]$statusMatch.Groups[2].Value + if ($errorOutput.Length -ne 0) { + $stderrCode = if ($errorOutput.Length -gt 4096) { + 'CONTROLLER_STDERR_LIMIT' + } else { 'CONTROLLER_STDERR_PRESENT' } + throw ('PROPR_WORKFLOW_CLEANUP_FIXTURE:{0}:STATUS:{1}:EXIT_CODE:{2}:' + + 'LINE_COUNT:{3}:STDERR_COUNT:{4}' -f ` + $stderrCode, $controllerStatus, $reportedExitCode, $lineCount, $stderrCount) + } + return [PSCustomObject]@{ + ExitCode = $process.ExitCode + Result = $resultName + ControllerStatus = $controllerStatus + ReportedExitCode = $reportedExitCode + StartupClass = [string]$statusMatch.Groups[3].Value + StartupProcessExit = [string]$statusMatch.Groups[4].Value + StartupLine = [string]$statusMatch.Groups[5].Value + Output = $output + } + } finally { + if (!$process.HasExited) { try { $process.Kill($true) } catch {} } + $process.Dispose() + } +} + +function Test-WorkflowCleanupStartupProtocol { + foreach ($failureClass in @('PARSER','PARAMETER_BINDING','TYPE_LOAD','OTHER')) { + $result = Invoke-WorkflowCleanupController ` + $dummyInstaller $([Guid]::NewGuid().ToString('N')) $testRoot 30000 $false ` + $failureClass + Assert-True ($result.ExitCode -eq 125 -and + $result.ReportedExitCode -eq 125 -and + $result.Result -ceq 'FAILED' -and + $result.ControllerStatus -ceq 'STARTUP_FAILURE' -and + $result.StartupClass -ceq $failureClass -and + $result.StartupProcessExit -match '^-?[0-9]+$' -and + $result.StartupLine -match '^[1-9][0-9]{0,5}$') ` + "native $failureClass startup fixture did not emit the fixed two-line protocol" + $startupDiagnostic = Get-SanitizedWorkflowCleanupResultDiagnostic $result + $expectedStartupDiagnostic = (( + 'EXIT_CODE:125:RESULT:FAILED:CONTROLLER_STATUS:STARTUP_FAILURE:' + + 'REPORTED_EXIT_CODE:125:STARTUP_CLASS:{0}:STARTUP_PROCESS_EXIT:{1}:' + + 'STARTUP_LINE:{2}') -f ` + $failureClass, $result.StartupProcessExit, $result.StartupLine) + Assert-True ($startupDiagnostic -ceq $expectedStartupDiagnostic) ` + "native $failureClass startup metadata was not preserved by the bounded diagnostic" + } + + foreach ($invalidStatusLine in @( + 'PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:STATUS:STARTUP_FAILURE:EXIT_CODE:125:STARTUP_CLASS:INVALID:PROCESS_EXIT:125:LINE:12', + 'PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:STATUS:STARTUP_FAILURE:EXIT_CODE:125:STARTUP_CLASS:PARSER:PROCESS_EXIT:+125:LINE:12', + 'PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:STATUS:STARTUP_FAILURE:EXIT_CODE:125:STARTUP_CLASS:PARSER:PROCESS_EXIT:125:LINE:-1' + )) { + Assert-True (!(Get-WorkflowCleanupControllerStatusMatch $invalidStatusLine).Success) ` + 'workflow cleanup parser accepted malformed startup metadata' + } + + $validStartupMetadata = [PSCustomObject]@{ + ExitCode = 125 + Result = 'FAILED' + ControllerStatus = 'STARTUP_FAILURE' + ReportedExitCode = 125 + StartupClass = 'PARSER' + StartupProcessExit = '-2147483648' + StartupLine = '999999' + } + Assert-True ((Get-SanitizedWorkflowCleanupResultDiagnostic $validStartupMetadata) -ceq ( + 'EXIT_CODE:125:RESULT:FAILED:CONTROLLER_STATUS:STARTUP_FAILURE:' + + 'REPORTED_EXIT_CODE:125:STARTUP_CLASS:PARSER:' + + 'STARTUP_PROCESS_EXIT:-2147483648:STARTUP_LINE:999999' + )) 'valid bounded startup metadata was not preserved' + + foreach ($invalidStartupMetadata in @( + [PSCustomObject]@{}, + [PSCustomObject]@{ + StartupClass = 'parser' + StartupProcessExit = '+125' + StartupLine = '0' + }, + [PSCustomObject]@{ + StartupClass = "PARSER`nDISCLOSURE" + StartupProcessExit = '2147483648' + StartupLine = '1000000' + } + )) { + $invalidStartupMetadata | Add-Member -NotePropertyName ExitCode -NotePropertyValue 125 + $invalidStartupMetadata | Add-Member -NotePropertyName Result -NotePropertyValue 'FAILED' + $invalidStartupMetadata | Add-Member ` + -NotePropertyName ControllerStatus -NotePropertyValue 'STARTUP_FAILURE' + $invalidStartupMetadata | Add-Member -NotePropertyName ReportedExitCode -NotePropertyValue 125 + Assert-True ((Get-SanitizedWorkflowCleanupResultDiagnostic $invalidStartupMetadata) -ceq ( + 'EXIT_CODE:125:RESULT:FAILED:CONTROLLER_STATUS:STARTUP_FAILURE:' + + 'REPORTED_EXIT_CODE:125:STARTUP_CLASS:INVALID:' + + 'STARTUP_PROCESS_EXIT:INVALID:STARTUP_LINE:INVALID' + )) 'invalid startup metadata did not fail closed to fixed sentinels' + } + + $nonStartupMetadata = [PSCustomObject]@{ + ExitCode = 21 + Result = 'FAILED' + ControllerStatus = 'OWNED_RESOURCE_CLEANUP_FAILURE' + ReportedExitCode = 21 + StartupClass = "PARSER`nDISCLOSURE" + StartupProcessExit = 'not-an-exit' + StartupLine = 'not-a-line' + } + Assert-True ((Get-SanitizedWorkflowCleanupResultDiagnostic $nonStartupMetadata) -ceq ( + 'EXIT_CODE:21:RESULT:FAILED:' + + 'CONTROLLER_STATUS:OWNED_RESOURCE_CLEANUP_FAILURE:REPORTED_EXIT_CODE:21' + )) 'non-startup cleanup diagnostic included startup-only metadata' + Write-Host 'PROPR_WINDOWS_SUPERVISOR_CONTROLLER_STARTUP:FIXED_PROTOCOL:PASSED' + [Console]::Out.Flush() +} + +function Start-ExternallyInterruptibleSupervisor([string]$StateDirectory) { + $scriptText = @' +param($SupervisorPath, $Installer, $Architecture, $FixtureWorker, $Scenario, + $StateDirectory, $Secret, $OwnedUser, $OwnedPassword, + $ConflictUser, $ConflictUserSid, $ConflictProfileSid, $ConflictProfilePath, + $ConflictDirectories, $ConflictShortcut, $ConflictRegistry) +$env:PROPR_SUPERVISOR_FIXTURE_SCENARIO = $Scenario +$env:PROPR_SUPERVISOR_FIXTURE_STATE_DIRECTORY = $StateDirectory +$env:PROPR_SUPERVISOR_FIXTURE_SECRET = $Secret +$env:PROPR_SUPERVISOR_FIXTURE_OWNED_USER = $OwnedUser +$env:PROPR_SUPERVISOR_FIXTURE_OWNED_PASSWORD = $OwnedPassword +$env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_USER = $ConflictUser +$env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_USER_SID = $ConflictUserSid +$env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_PROFILE_SID = $ConflictProfileSid +$env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_PROFILE_PATH = $ConflictProfilePath +$env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_DIRECTORIES = $ConflictDirectories +$env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_SHORTCUT = $ConflictShortcut +$env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_REGISTRY = $ConflictRegistry +& $SupervisorPath -Installer $Installer -Architecture $Architecture ` + -WorkerPath $FixtureWorker -FixtureCleanupRoot $StateDirectory ` + -BootstrapTimeoutMilliseconds 10000 -WatchdogPollMilliseconds 25 ` + -WatchdogTerminationMilliseconds 3000 -PostTerminationCleanupMilliseconds 30000 ` + -MarkerReadTimeoutMilliseconds 200 +'@ + $pipeline = [Management.Automation.PowerShell]::Create() + [void]$pipeline.AddScript($scriptText) + foreach ($argument in @( + $supervisorPath, + $dummyInstaller, + $Architecture, + $fixtureWorkerPath, + 'OWNED_RESOURCES_FOR_INTERRUPTION', + $StateDirectory, + $secretNeedle, + $ownedFixtureUserName, + $ownedFixturePassword, + $conflictingFixtureUserName, + $conflictingFixtureUserSid, + $conflictingFixtureProfileSid, + $conflictingFixtureProfilePath, + $conflictingFixtureDirectories, + $conflictingFixtureShortcut, + $conflictingFixtureRegistryPath + )) { + [void]$pipeline.AddArgument($argument) + } + $asyncResult = $pipeline.BeginInvoke() + return [PSCustomObject]@{ Pipeline = $pipeline; AsyncResult = $asyncResult } +} + +function Invoke-FixtureScenario( + [string]$Scenario, + [string]$ExistingStateDirectory = '', + [bool]$InjectTerminationFailure = $false +) { + $stateDirectory = if ($ExistingStateDirectory) { + $ExistingStateDirectory + } else { + New-StateDirectory $Scenario.ToLowerInvariant() + } + $process = [Diagnostics.Process]::new() + $process.StartInfo = New-SupervisorStartInfo ` + $Scenario $stateDirectory '' $false '' '' $InjectTerminationFailure + $stopwatch = [Diagnostics.Stopwatch]::StartNew() + if (!$process.Start()) { throw 'supervisor test process did not start' } + try { + $completionBound = if ($Scenario -in @( + 'NO_MARKER','NO_MARKER_WINDOWS_POWERSHELL' + )) { + 60000 + } elseif ($Scenario -in @( + 'OWNED_RESOURCES_THEN_DEADLINE', + 'OWNED_RESOURCES_REPLACED_THEN_DEADLINE', + 'OWNED_EXECUTABLE_REPLACED_THEN_DEADLINE', + 'OWNED_EXECUTABLE_BYTE_IDENTICAL_REPLACED_THEN_DEADLINE', + 'OWNED_SHORTCUT_REPLACED_THEN_DEADLINE', + 'OWNED_PROFILE_PATH_MISMATCH_THEN_DEADLINE', + 'OWNED_RESOURCES_FOREIGN_CHILD_THEN_DEADLINE', + 'SMOKE_BEFORE_PROMOTION_THEN_DEADLINE', + 'SMOKE_AFTER_PROMOTION_THEN_DEADLINE', + 'SMOKE_AFTER_ARTIFACTS_THEN_DEADLINE', + 'SMOKE_FOREIGN_DESCENDANT_THEN_DEADLINE', + 'SMOKE_TOKEN_MISMATCH_THEN_DEADLINE' + )) { 90000 } else { 20000 } + if (!$process.WaitForExit($completionBound)) { + try { $process.Kill($true) } catch {} + throw 'supervisor exceeded the executable test completion bound' + } + $stopwatch.Stop() + $standardOutput = $process.StandardOutput.ReadToEnd() + $standardError = $process.StandardError.ReadToEnd() + $state = Read-FixtureProcessState $stateDirectory + Assert-ProcessTreeGone $state + return [PSCustomObject]@{ + ExitCode = $process.ExitCode + ElapsedMilliseconds = $stopwatch.ElapsedMilliseconds + Output = $standardOutput + Error = $standardError + StateDirectory = $stateDirectory + } + } finally { + $process.Dispose() + } +} + +function Invoke-CriticalCancellationScenario([string]$Scenario) { + $stateDirectory = New-StateDirectory $Scenario.ToLowerInvariant() + $eventName = "Local\ProPRInstalledAppCancellation-$([Guid]::NewGuid().ToString('N'))" + $cancellation = [Threading.EventWaitHandle]::new( + $false, [Threading.EventResetMode]::ManualReset, $eventName) + $process = [Diagnostics.Process]::new() + $process.StartInfo = New-SupervisorStartInfo ` + $Scenario $stateDirectory $eventName $false + try { + if (!$process.Start()) { throw 'critical-cancellation supervisor did not start' } + $gatePath = Join-Path $stateDirectory 'critical-gate.txt' + $gateWait = [Diagnostics.Stopwatch]::StartNew() + while (!(Test-Path -LiteralPath $gatePath -PathType Leaf)) { + if ($gateWait.ElapsedMilliseconds -ge 45000) { + throw 'critical-cancellation fixture did not reach its interruption gate' + } + Start-Sleep -Milliseconds 25 + } + Assert-True ((Get-Content -LiteralPath $gatePath -Raw -Encoding ASCII) -ceq $Scenario) ` + 'critical-cancellation fixture published the wrong interruption gate' + [void]$cancellation.Set() + Assert-True ($process.WaitForExit(90000)) ` + 'critical-cancellation supervisor exceeded its fixed completion bound' + $output = $process.StandardOutput.ReadToEnd() + $errorOutput = $process.StandardError.ReadToEnd() + Assert-ProcessTreeGone (Read-FixtureProcessState $stateDirectory) + return [PSCustomObject]@{ + ExitCode = $process.ExitCode + Output = $output + Error = $errorOutput + StateDirectory = $stateDirectory + } + } finally { + if (!$process.HasExited) { try { $process.Kill($true) } catch {} } + $process.Dispose() + $cancellation.Dispose() + } +} + +function Test-MsiTransactionInterruptionGates { + $duringMsi = Invoke-CriticalCancellationScenario 'DURING_MSI' + Assert-True ($duringMsi.ExitCode -eq 125) ` + 'DURING_MSI cancellation did not preserve the supervisor cancellation status' + Assert-Contains $duringMsi.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:MSI_TRANSACTION:GRACE' ` + 'DURING_MSI cancellation did not enter the fixed transaction grace' + Assert-Contains $duringMsi.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:MSI_TRANSACTION:ROLLED_BACK_CLEAN' ` + 'DURING_MSI cancellation did not prove the exact clean rollback receipt' + Assert-Contains $duringMsi.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:COMPLETE' ` + 'DURING_MSI clean rollback did not complete bounded cleanup' + Assert-True (!(Test-Path -LiteralPath (Join-Path $duringMsi.StateDirectory 'owned'))) ` + 'DURING_MSI rollback did not retain the exact clean fixture baseline' + + $duringCapture = Invoke-CriticalCancellationScenario 'DURING_OWNERSHIP_CAPTURE' + $duringCaptureDiagnostic = Get-SanitizedCriticalCancellationDiagnostic $duringCapture + Assert-True ($duringCapture.ExitCode -eq 125) ` + "DURING_OWNERSHIP_CAPTURE cancellation did not preserve cancellation status:$duringCaptureDiagnostic" + Assert-Contains $duringCapture.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:MSI_TRANSACTION:COMMITTED' ` + "DURING_OWNERSHIP_CAPTURE did not publish durable nonprovisional authority:$duringCaptureDiagnostic" + Assert-Contains $duringCapture.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:COMPLETE' ` + "DURING_OWNERSHIP_CAPTURE durable authority did not complete cleanup:$duringCaptureDiagnostic" + $capturedOwned = Read-FixtureResourceState $duringCapture.StateDirectory + Assert-OwnedResourcesGone $capturedOwned +} + +function Test-BootstrapTimeout { + $result = Invoke-FixtureScenario 'NO_MARKER' + $diagnostic = Get-SanitizedSupervisorMarkerDiagnostic $result + Assert-True ([string]::IsNullOrEmpty([string]$result.Error)) ` + 'missing-marker native pwsh fixture emitted stderr' + Assert-True ($result.ExitCode -eq 124) ` + "missing-marker bootstrap did not fail with the watchdog code:$diagnostic" + Assert-True ($result.ElapsedMilliseconds -ge 9000) 'bootstrap timeout ignored the injected deadline' + Assert-True ($result.ElapsedMilliseconds -lt 60000) 'missing-marker bootstrap completion was not bounded' + Assert-Contains $result.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:BOOTSTRAP:TIMED_OUT' ` + 'missing-marker bootstrap did not emit the fixed timeout line' + Assert-Contains $result.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:LAST_VALID:NONE' ` + 'missing-marker bootstrap did not emit the fixed empty last-stage line' + Assert-Contains $result.Output ` + ('PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:FIXTURE_FINALIZATION:' + + 'WORKER_TREE_TERMINATION:COMPLETE') ` + 'missing-marker bootstrap did not verify worker-tree termination' + Assert-Contains $result.Output ` + ('PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:FIXTURE_FINALIZATION:' + + 'CLEANUP_CHILD_EXIT:0') ` + 'missing-marker bootstrap cleanup child did not consume the empty authority' + Assert-Contains $result.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:COMPLETE' ` + 'missing-marker bootstrap did not complete bounded cleanup' +} + +function Test-WindowsPowerShellCleanupCompatibility { + # This separate scenario runs the same supervisor-written initial ACTIVE + # receipt through the Windows PowerShell 5.1 cleanup reader/finalizer. + $result = Invoke-FixtureScenario 'NO_MARKER_WINDOWS_POWERSHELL' + $diagnostic = Get-SanitizedSupervisorMarkerDiagnostic $result + Assert-True ([string]::IsNullOrEmpty([string]$result.Error)) ` + 'Windows PowerShell cleanup compatibility fixture emitted stderr' + Assert-True ($result.ExitCode -eq 124) ` + "Windows PowerShell cleanup compatibility did not preserve watchdog exit:$diagnostic" + Assert-Contains $result.Output ` + ('PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:FIXTURE_FINALIZATION:' + + 'CLEANUP_CHILD_EXIT:0') ` + 'Windows PowerShell cleanup compatibility did not consume exact identifiers' + Assert-Contains $result.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:COMPLETE' ` + 'Windows PowerShell cleanup compatibility did not complete' +} + +function Test-OperationDeadlineAndTreeTermination { + $result = Invoke-FixtureScenario 'VALID_THEN_DEADLINE' + Assert-True ($result.ExitCode -eq 124) 'operation deadline did not fail with the watchdog code' + Assert-True ($result.ElapsedMilliseconds -ge 2200) ` + 'operation deadline did not retain the injected observable interval' + Assert-True ($result.ElapsedMilliseconds -lt 10000) ` + 'operation deadline completion was not bounded' + Assert-Contains $result.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:ACCEPTED:VALIDATION:INSTALL_TREE_SCAN:BEGIN' ` + 'operation transition was not accepted and flushed by the supervisor' + Assert-Contains $result.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:VALIDATION:INSTALL_TREE_SCAN:BEGIN:TIMED_OUT' ` + 'operation deadline did not emit the fixed redacted timeout line' +} + +function Test-NegativeWorkerExitFinalization { + $result = Invoke-FixtureScenario 'NEGATIVE_EXIT' + Assert-True ($result.ExitCode -eq -1) ` + 'negative worker exit status was not preserved after bounded finalization' + Assert-Contains $result.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:ACCEPTED:INITIALIZATION:PATHS:BEGIN' ` + 'negative-exit fixture did not publish a valid marker before crashing' + Assert-Contains $result.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:COMPLETE' ` + 'negative worker exit did not enter bounded tree termination and cleanup' +} + +function Test-FailClosedMarkers { + foreach ($testCase in @( + @{ Scenario = 'MALFORMED_MARKER'; Label = 'malformed' }, + @{ Scenario = 'TORN_MARKER'; Label = 'torn' }, + @{ Scenario = 'STALE_MARKER'; Label = 'stale' }, + @{ Scenario = 'INACCESSIBLE_MARKER'; Label = 'inaccessible' } + )) { + $result = Invoke-FixtureScenario $testCase.Scenario + Assert-True ($result.ExitCode -eq 124) "$($testCase.Label) marker did not fail closed" + Assert-Contains $result.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:BOOTSTRAP:FAILED' ` + "$($testCase.Label) marker did not emit the fixed bootstrap failure line" + Assert-NotContains $result.Output $secretNeedle ` + "$($testCase.Label) marker diagnostics exposed fixture-sensitive data" + } +} + +function Test-LiveCancellationAndRedaction { + $stateDirectory = New-StateDirectory 'cancellation' + $eventName = "Local\ProPRInstalledAppCancellation-$([Guid]::NewGuid().ToString('N'))" + $cancellationEvent = [Threading.EventWaitHandle]::new( + $false, + [Threading.EventResetMode]::ManualReset, + $eventName + ) + $process = [Diagnostics.Process]::new() + $process.StartInfo = New-SupervisorStartInfo 'CANCELLATION' $stateDirectory $eventName $false + $lines = [Collections.Generic.List[string]]::new() + try { + if (!$process.Start()) { throw 'cancellation supervisor did not start' } + $liveAccepted = $false + $readStopwatch = [Diagnostics.Stopwatch]::StartNew() + while (!$liveAccepted -and $readStopwatch.ElapsedMilliseconds -lt 8000) { + $lineTask = $process.StandardOutput.ReadLineAsync() + if (!$lineTask.Wait(8000 - [int]$readStopwatch.ElapsedMilliseconds)) { break } + $line = $lineTask.Result + if ($null -eq $line) { break } + $lines.Add($line) + if ($line -ceq 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:ACCEPTED:INITIALIZATION:PATHS:BEGIN') { + $liveAccepted = $true + } + } + Assert-True $liveAccepted 'accepted transition was not observable live before cancellation' + Assert-True (!$process.HasExited) 'supervisor exited before simulated cancellation' + [void]$cancellationEvent.Set() + Assert-True ($process.WaitForExit(8000)) 'cancelled supervisor did not complete within the bound' + $remainingOutput = $process.StandardOutput.ReadToEnd() + if ($remainingOutput) { $lines.Add($remainingOutput) } + $standardError = $process.StandardError.ReadToEnd() + $output = $lines -join "`n" + Assert-True ($process.ExitCode -eq 125) 'simulated cancellation did not use the supervisor failure code' + Assert-Contains $output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:SUPERVISOR:CANCELLED' ` + 'simulated cancellation did not emit the fixed cancellation line' + Assert-True ($output -match ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:LAST_VALID:(?:INITIALIZATION:PATHS|VALIDATION:INSTALL_TREE_SCAN):BEGIN') ` + 'simulated cancellation did not emit a fixed last-valid-marker line' + foreach ($forbidden in @($secretNeedle, $stateDirectory, $testRoot, 'fixture-user', 'credential')) { + Assert-NotContains $output $forbidden 'live supervisor diagnostics were not redacted' + } + $state = Read-FixtureProcessState $stateDirectory + Assert-ProcessTreeGone $state + Assert-True ([string]::IsNullOrEmpty($standardError)) 'fixture cancellation wrote unexpected stderr' + } finally { + if (!$process.HasExited) { try { $process.Kill($true) } catch {} } + $process.Dispose() + $cancellationEvent.Dispose() + } +} + +function Get-RunnerProfileSnapshot { + try { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + Assert-True ($null -ne $identity -and $null -ne $identity.User) ` + 'runner profile authority validation failed' + $identitySid = $identity.User.Value + Assert-True (![string]::IsNullOrWhiteSpace($identitySid)) ` + 'runner profile authority validation failed' + + $profiles = @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop | Where-Object { + $_.SID -ceq $identitySid + }) + Assert-True ($profiles.Count -eq 1) 'runner profile authority validation failed' + $profile = $profiles[0] + Assert-True (!$profile.Special -and $profile.Loaded) ` + 'runner profile authority validation failed' + Assert-True (![string]::IsNullOrWhiteSpace([string]$profile.LocalPath) -and + [IO.Path]::IsPathRooted([string]$profile.LocalPath)) ` + 'runner profile authority validation failed' + + $rawCimLocalPath = [string]$profile.LocalPath + $cimLocalPath = $rawCimLocalPath.TrimEnd('\') + Assert-True ($rawCimLocalPath -ceq $cimLocalPath) ` + 'runner profile authority validation failed' + $canonicalLocalPath = [IO.Path]::GetFullPath($cimLocalPath).TrimEnd('\') + Assert-True ([string]::Equals( + $cimLocalPath, + $canonicalLocalPath, + [StringComparison]::Ordinal + )) 'runner profile authority validation failed' + $resolvedProfilePath = Resolve-Path -LiteralPath $canonicalLocalPath -ErrorAction Stop + $resolvedLocalPath = $resolvedProfilePath.ProviderPath.TrimEnd('\') + Assert-True ([string]::Equals( + $resolvedLocalPath, + $canonicalLocalPath, + [StringComparison]::Ordinal + )) 'runner profile authority validation failed' + + $profileDirectory = Get-Item -LiteralPath $canonicalLocalPath -Force -ErrorAction Stop + Assert-True ($profileDirectory.PSIsContainer) 'runner profile authority validation failed' + $pathCursor = $profileDirectory + while ($null -ne $pathCursor) { + Assert-True (($pathCursor.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) ` + 'runner profile authority validation failed' + $parentPath = Split-Path -Parent $pathCursor.FullName + if ([string]::IsNullOrEmpty($parentPath) -or + [string]::Equals($parentPath, $pathCursor.FullName, [StringComparison]::OrdinalIgnoreCase)) { + break + } + $pathCursor = Get-Item -LiteralPath $parentPath -Force -ErrorAction Stop + } + + $profileOwner = (Get-Acl -LiteralPath $canonicalLocalPath -ErrorAction Stop).Owner + Assert-True (![string]::IsNullOrWhiteSpace($profileOwner)) ` + 'runner profile authority validation failed' + $profileOwnerSid = if ($profileOwner -match '^S-\d+(?:-\d+)+$') { + [Security.Principal.SecurityIdentifier]::new($profileOwner).Value + } else { + $profileOwnerAccount = [Security.Principal.NTAccount]::new($profileOwner) + $profileOwnerAccount.Translate([Security.Principal.SecurityIdentifier]).Value + } + + return [PSCustomObject]@{ + ProfileExists = $true + DirectoryExists = $true + IdentitySid = $identitySid + ProfileSid = [string]$profile.SID + CimLocalPath = $cimLocalPath + CanonicalLocalPath = $canonicalLocalPath + DirectoryOwnerSid = $profileOwnerSid + DirectoryAttributes = [int64]$profileDirectory.Attributes + Loaded = [bool]$profile.Loaded + Special = [bool]$profile.Special + Status = [uint32]$profile.Status + HealthStatus = [uint32]$profile.HealthStatus + RoamingConfigured = [bool]$profile.RoamingConfigured + RoamingPath = [string]$profile.RoamingPath + RoamingPreference = [bool]$profile.RoamingPreference + } + } catch { + throw 'runner profile authority validation failed' + } finally { + if ($null -ne $identity) { $identity.Dispose() } + } +} + +function Assert-RunnerProfileUnchanged($Before) { + $after = Get-RunnerProfileSnapshot + $unchanged = $after.ProfileExists -and $Before.ProfileExists -and + $after.DirectoryExists -and $Before.DirectoryExists -and + $after.IdentitySid -ceq $Before.IdentitySid -and + $after.ProfileSid -ceq $Before.ProfileSid -and + $after.CimLocalPath -ceq $Before.CimLocalPath -and + $after.CanonicalLocalPath -ceq $Before.CanonicalLocalPath -and + $after.DirectoryOwnerSid -ceq $Before.DirectoryOwnerSid -and + $after.DirectoryAttributes -eq $Before.DirectoryAttributes -and + $after.Loaded -eq $Before.Loaded -and + $after.Special -eq $Before.Special -and + $after.Status -eq $Before.Status -and + $after.HealthStatus -eq $Before.HealthStatus -and + $after.RoamingConfigured -eq $Before.RoamingConfigured -and + $after.RoamingPath -ceq $Before.RoamingPath -and + $after.RoamingPreference -eq $Before.RoamingPreference + Assert-True $unchanged 'runner profile authority changed during ownership test' +} + +function Test-PreExistingCleanupOwnership { + $runnerProfileBefore = Get-RunnerProfileSnapshot + $stateDirectory = New-StateDirectory 'ownership' + $conflictRoot = Join-Path $stateDirectory 'pre-existing' + $conflictInstallRoot = Join-Path $conflictRoot 'install-tree' + $conflictShortcutFolder = Join-Path $conflictRoot 'shortcut-folder' + $conflictShortcut = Join-Path $conflictShortcutFolder 'ProPR Desktop.lnk' + $conflictSmokeDirectory = Join-Path $conflictRoot 'smoke-data' + $conflictRegistryPath = "Registry::HKEY_LOCAL_MACHINE\Software\ProPRSupervisorFixture\conflict-$([Guid]::NewGuid().ToString('N'))" + $userName = "prpr$([Guid]::NewGuid().ToString('N').Substring(0,8))" + $password = ConvertTo-SecureString "P!$([Guid]::NewGuid().ToString('N'))z9" -AsPlainText -Force + $userCreated = $false + $registryCreated = $false + $userSid = $null + try { + Assert-True ($null -eq (Get-LocalUser -Name $userName -ErrorAction SilentlyContinue)) ` + 'pre-existing local user fixture baseline was not clean' + $createdUser = New-LocalUser -Name $userName -Password $password ` + -AccountNeverExpires -PasswordNeverExpires + $userCreated = $true + $userSid = $createdUser.SID + Assert-True ($null -ne $userSid) 'pre-existing local user fixture ownership capture failed' + $capturedUser = Get-LocalUser -Name $userName -ErrorAction Stop + Assert-True ($capturedUser.SID.Equals($userSid)) ` + 'pre-existing local user fixture ownership capture failed' + $fixtureUserProfiles = @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop | + Where-Object { $_.SID -ceq $userSid.Value }) + Assert-True ($fixtureUserProfiles.Count -eq 0) ` + 'pre-existing local user fixture unexpectedly acquired a profile' + + foreach ($directory in @( + $conflictInstallRoot, $conflictShortcutFolder, $conflictSmokeDirectory + )) { + [void](New-Item -ItemType Directory -Path $directory -Force -ErrorAction Stop) + Set-Content -LiteralPath (Join-Path $directory 'pre-existing.txt') -Value 'owned-before-run' + } + Set-Content -LiteralPath $conflictShortcut -Value 'owned-before-run' + [void](New-Item -Path $conflictRegistryPath -Force -ErrorAction Stop) + $registryCreated = $true + Set-ItemProperty -LiteralPath $conflictRegistryPath -Name 'PreExisting' -Value 'owned-before-run' + + $script:conflictingFixtureUserName = $userName + $script:conflictingFixtureUserSid = $userSid.Value + $script:conflictingFixtureProfileSid = $runnerProfileBefore.ProfileSid + $script:conflictingFixtureProfilePath = $runnerProfileBefore.CanonicalLocalPath + $script:conflictingFixtureDirectories = @( + $conflictInstallRoot, $conflictShortcutFolder, $conflictSmokeDirectory + ) -join '|' + $script:conflictingFixtureShortcut = $conflictShortcut + $script:conflictingFixtureRegistryPath = $conflictRegistryPath + + $result = Invoke-FixtureScenario 'OWNED_RESOURCES_THEN_DEADLINE' $stateDirectory + Assert-True ($result.ExitCode -eq 124) 'owned-resource timeout did not preserve watchdog status' + Assert-Contains $result.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:CLEANUP:SMOKE_DATA_REMOVE:BEGIN:TIMED_OUT' ` + 'owned-resource fixture did not reach the forced timeout boundary' + Assert-Contains $result.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:COMPLETE' ` + 'forced timeout did not execute bounded post-termination cleanup' + $redactedEvidence = "$($result.Output)`n$($result.Error)" + foreach ($forbidden in @( + $runnerProfileBefore.IdentitySid, + $runnerProfileBefore.CanonicalLocalPath, + $userName, + $userSid.Value, + $ownedFixtureUserName, + $ownedFixturePassword + )) { + Assert-NotContains $redactedEvidence $forbidden ` + 'ownership cleanup evidence exposed an identity or credential' + } + + $owned = Read-FixtureResourceState $stateDirectory + foreach ($ownedPath in @( + $owned.OwnedRoot, $owned.InstallRoot, $owned.ShortcutFolder, + $owned.Shortcut, $owned.SmokeDirectory + )) { + Assert-True (!(Test-Path -LiteralPath $ownedPath)) ` + 'post-termination cleanup left a run-owned file-system resource behind' + } + Assert-True (!(Test-Path -LiteralPath $owned.RegistryPath)) ` + 'post-termination cleanup left a run-owned registry resource behind' + Assert-True (!(Test-Path -LiteralPath $owned.RegistryRoot)) ` + 'post-termination cleanup left the run-owned registry root behind' + Assert-True ($null -eq (Get-LocalUser -Name $owned.UserName -ErrorAction SilentlyContinue)) ` + 'post-termination cleanup left the run-owned local user behind' + $ownedProfiles = @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop | + Where-Object { $_.SID -ceq $owned.UserSid }) + Assert-True ($ownedProfiles.Count -eq 0) ` + 'post-termination cleanup left the run-owned profile behind' + + $replacementStateDirectory = New-StateDirectory 'replacement-collision' + $replacementResult = Invoke-FixtureScenario ` + 'OWNED_RESOURCES_REPLACED_THEN_DEADLINE' $replacementStateDirectory + Assert-True ($replacementResult.ExitCode -eq 125) ` + 'replacement collision did not fail the standalone cleanup' + Assert-Contains $replacementResult.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' ` + 'replacement collision did not emit fixed cleanup failure evidence' + $replacementOwned = Read-FixtureResourceState $replacementStateDirectory + Assert-ReplacedFixtureResourcesSurvive $replacementOwned + Assert-True (Test-Path -LiteralPath $replacementOwned.ManifestPath -PathType Leaf) ` + 'false standalone cleanup result discarded authenticated recovery authority' + Restore-ReplacedFixtureAuthority $replacementOwned + $replacementRetry = Invoke-WorkflowCleanupController ` + $replacementOwned.ManifestPath $replacementOwned.RunId $replacementStateDirectory + $replacementRetryDiagnostic = + Get-SanitizedWorkflowCleanupResultDiagnostic $replacementRetry + Assert-True ($replacementRetry.ExitCode -eq 0 -and + $replacementRetry.Result -ceq 'COMPLETE') ` + "standalone cleanup did not retry to exact success after authority restoration:$replacementRetryDiagnostic" + Assert-OwnedResourcesGone $replacementOwned + Assert-True (!(Test-Path -LiteralPath $replacementOwned.ManifestPath)) ` + 'successful standalone cleanup retry did not consume recovery authority' + + foreach ($replacementCase in @( + [PSCustomObject]@{ + Scenario = 'OWNED_EXECUTABLE_REPLACED_THEN_DEADLINE' + Directory = 'replaced-executable' + Label = 'executable' + }, + [PSCustomObject]@{ + Scenario = 'OWNED_SHORTCUT_REPLACED_THEN_DEADLINE' + Directory = 'replaced-shortcut' + Label = 'shortcut' + } + )) { + $replacedStateDirectory = New-StateDirectory $replacementCase.Directory + $replacedResult = Invoke-FixtureScenario ` + $replacementCase.Scenario $replacedStateDirectory + Assert-True ($replacedResult.ExitCode -eq 125) ` + "replacement $($replacementCase.Label) did not fail before cleanup" + Assert-Contains $replacedResult.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' ` + "replacement $($replacementCase.Label) did not emit fixed cleanup failure evidence" + $replacedOwned = Read-FixtureResourceState $replacedStateDirectory + if ($replacementCase.Label -ceq 'executable') { + Assert-ReplacedExecutableSurvives $replacedOwned + } else { + Assert-ReplacedShortcutSurvives $replacedOwned + } + Assert-MsiPreflightPreservedResources $replacedOwned + $replacedManifest = Get-Content -LiteralPath $replacedOwned.ManifestPath ` + -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop + Assert-True ($replacedManifest.State -ceq 'ACTIVE') ` + "replacement $($replacementCase.Label) discarded ACTIVE recovery authority" + Restore-ReplacedFixtureAuthority $replacedOwned + $replacedRetry = Invoke-WorkflowCleanupController ` + $replacedOwned.ManifestPath $replacedOwned.RunId $replacedStateDirectory + Assert-True ($replacedRetry.ExitCode -eq 0 -and + $replacedRetry.Result -ceq 'COMPLETE') ` + "replacement $($replacementCase.Label) authority did not retry to success" + Assert-OwnedResourcesGone $replacedOwned + } + + $profileMismatchDirectory = New-StateDirectory 'profile-path-mismatch' + $profileMismatchResult = Invoke-FixtureScenario ` + 'OWNED_PROFILE_PATH_MISMATCH_THEN_DEADLINE' $profileMismatchDirectory + Assert-True ($profileMismatchResult.ExitCode -eq 125) ` + 'mismatched durable profile path did not fail closed' + Assert-Contains $profileMismatchResult.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' ` + 'mismatched durable profile path did not emit fixed cleanup failure evidence' + $profileMismatchOwned = Read-FixtureResourceState $profileMismatchDirectory + $survivingProfiles = @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop | + Where-Object { $_.SID -ceq [string]$profileMismatchOwned.UserSid }) + Assert-True ($survivingProfiles.Count -eq 1) ` + 'mismatched durable path selected the owned profile for deletion' + $survivingProfilePath = (Resolve-Path -LiteralPath ` + ([string]$survivingProfiles[0].LocalPath) -ErrorAction Stop).ProviderPath.TrimEnd('\') + Assert-True ([string]::Equals( + $survivingProfilePath, + ([string]$profileMismatchOwned.ProfilePath).TrimEnd('\'), + [StringComparison]::OrdinalIgnoreCase + )) 'mismatched-path regression did not preserve the exact live profile' + $profileMismatchManifest = Get-Content -LiteralPath $profileMismatchOwned.ManifestPath ` + -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop + Assert-True ($profileMismatchManifest.State -ceq 'ACTIVE') ` + 'mismatched profile path discarded ACTIVE recovery authority' + $profileMismatchUsers = @($profileMismatchManifest.Users | Where-Object { + $_.Owned -and [string]$_.Sid -ceq [string]$profileMismatchOwned.UserSid + }) + $remainingProfileUser = Get-LocalUser -Name $profileMismatchOwned.UserName ` + -ErrorAction Stop + Assert-True ($profileMismatchUsers.Count -eq 1 -and + [string]$remainingProfileUser.SID.Value -ceq [string]$profileMismatchOwned.UserSid -and + [string]$remainingProfileUser.Description -ceq + [string]$profileMismatchUsers[0].OwnershipMarker) ` + 'mismatched profile path discarded authenticated marker and SID authority' + $ownedProfileRecords = @($profileMismatchManifest.Profiles | Where-Object { + $_.Owned -and [string]$_.Sid -ceq [string]$profileMismatchOwned.UserSid + }) + Assert-True ($ownedProfileRecords.Count -eq 1 -and + [string]::Equals( + [string]$ownedProfileRecords[0].LocalPath, + [string]$profileMismatchOwned.MismatchedProfilePath, + [StringComparison]::OrdinalIgnoreCase + )) 'mismatched durable profile record was silently re-authorized' + + # A canonical profile belonging to another direct child is still not an + # owned path: its leaf is not the authenticated run username. + $ownedProfileRecords[0].LocalPath = $runnerProfileBefore.CanonicalLocalPath + Write-TestOwnershipManifest $profileMismatchOwned.ManifestPath $profileMismatchManifest + $alternateLeafCleanup = Invoke-WorkflowCleanupController ` + $profileMismatchOwned.ManifestPath $profileMismatchOwned.RunId $profileMismatchDirectory + Assert-True ($alternateLeafCleanup.ExitCode -eq 21 -and + $alternateLeafCleanup.Result -ceq 'FAILED') ` + 'alternate ProfilesDirectory leaf did not fail closed' + $alternateLeafProfiles = @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop | + Where-Object { $_.SID -ceq [string]$profileMismatchOwned.UserSid }) + Assert-True ($alternateLeafProfiles.Count -eq 1) ` + 'alternate ProfilesDirectory leaf selected the owned profile for deletion' + $alternateLeafManifest = Get-Content -LiteralPath $profileMismatchOwned.ManifestPath ` + -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop + Assert-True ($alternateLeafManifest.State -ceq 'ACTIVE') ` + 'alternate ProfilesDirectory leaf discarded ACTIVE recovery authority' + + $ownedProfileRecords[0].LocalPath = [string]$profileMismatchOwned.ProfilePath + Write-TestOwnershipManifest $profileMismatchOwned.ManifestPath $profileMismatchManifest + $profileMismatchRetry = Invoke-WorkflowCleanupController ` + $profileMismatchOwned.ManifestPath $profileMismatchOwned.RunId $profileMismatchDirectory + Assert-True ($profileMismatchRetry.ExitCode -eq 0 -and + $profileMismatchRetry.Result -ceq 'COMPLETE') ` + 'profile cleanup did not succeed after exact durable path restoration' + Assert-OwnedResourcesGone $profileMismatchOwned + + $byteIdenticalDirectory = New-StateDirectory 'byte-identical-replaced-executable' + $byteIdenticalResult = Invoke-FixtureScenario ` + 'OWNED_EXECUTABLE_BYTE_IDENTICAL_REPLACED_THEN_DEADLINE' $byteIdenticalDirectory + Assert-True ($byteIdenticalResult.ExitCode -eq 125) ` + 'byte-identical replace-via-move did not fail closed on entry identity' + $byteIdenticalOwned = Read-FixtureResourceState $byteIdenticalDirectory + Assert-ReplacedExecutableSurvives $byteIdenticalOwned + $byteIdenticalManifest = Get-Content -LiteralPath $byteIdenticalOwned.ManifestPath ` + -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop + Assert-True ($byteIdenticalManifest.State -ceq 'ACTIVE') ` + 'byte-identical replace-via-move discarded ACTIVE recovery authority' + Remove-Item -LiteralPath $byteIdenticalOwned.Executable -Force -ErrorAction Stop + Move-Item -LiteralPath $byteIdenticalOwned.ExecutableBackup ` + -Destination $byteIdenticalOwned.Executable -ErrorAction Stop + $byteIdenticalRetry = Invoke-WorkflowCleanupController ` + $byteIdenticalOwned.ManifestPath $byteIdenticalOwned.RunId $byteIdenticalDirectory + Assert-True ($byteIdenticalRetry.ExitCode -eq 0 -and + $byteIdenticalRetry.Result -ceq 'COMPLETE') ` + 'byte-identical file cleanup did not succeed after exact entry identity restoration' + Assert-True (!(Test-Path -LiteralPath $byteIdenticalOwned.Executable) -and + !(Test-Path -LiteralPath $byteIdenticalOwned.ManifestPath)) ` + 'byte-identical file retry did not consume the exact owned entry and authority' + + $foreignChildStateDirectory = New-StateDirectory 'in-place-foreign-child' + $foreignChildResult = Invoke-FixtureScenario ` + 'OWNED_RESOURCES_FOREIGN_CHILD_THEN_DEADLINE' $foreignChildStateDirectory + Assert-True ($foreignChildResult.ExitCode -eq 125) ` + 'in-place foreign child did not fail the standalone cleanup' + Assert-Contains $foreignChildResult.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' ` + 'in-place foreign child did not emit fixed cleanup failure evidence' + $foreignChildOwned = Read-FixtureResourceState $foreignChildStateDirectory + $foreignChildPath = Join-Path $foreignChildOwned.InstallRoot 'foreign-in-place.txt' + Assert-True ((Get-Content -LiteralPath $foreignChildPath -Raw).Trim() -ceq ` + 'foreign-in-place') 'in-place foreign child was removed or changed' + Assert-True (Test-Path -LiteralPath $foreignChildOwned.ManifestPath -PathType Leaf) ` + 'in-place foreign-child failure discarded authenticated recovery authority' + $foreignChildManifest = Get-Content -LiteralPath $foreignChildOwned.ManifestPath ` + -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop + Assert-True ($foreignChildManifest.State -ceq 'ACTIVE') ` + 'in-place foreign-child failure did not preserve the ACTIVE manifest' + Remove-Item -LiteralPath $foreignChildPath -Force -ErrorAction Stop + $foreignChildRetry = Invoke-WorkflowCleanupController ` + $foreignChildOwned.ManifestPath $foreignChildOwned.RunId $foreignChildStateDirectory + Assert-True ($foreignChildRetry.ExitCode -eq 0 -and + $foreignChildRetry.Result -ceq 'COMPLETE') ` + 'in-place foreign-child cleanup did not retry to exact success' + Assert-OwnedResourcesGone $foreignChildOwned + + $terminationFailureStateDirectory = New-StateDirectory 'termination-failure' + $terminationFailureResult = Invoke-FixtureScenario ` + 'OWNED_RESOURCES_THEN_DEADLINE' $terminationFailureStateDirectory $true + Assert-True ($terminationFailureResult.ExitCode -eq 125) ` + 'unverified worker-tree termination did not fail closed' + Assert-Contains $terminationFailureResult.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' ` + 'unverified worker-tree termination did not emit fixed failure evidence' + $terminationFailureOwned = Read-FixtureResourceState $terminationFailureStateDirectory + Assert-ProcessTreeGone (Read-FixtureProcessState $terminationFailureStateDirectory) + Assert-True (Test-Path -LiteralPath $terminationFailureOwned.ManifestPath -PathType Leaf) ` + 'termination failure discarded authenticated recovery authority' + $terminationFailureManifest = Get-Content ` + -LiteralPath $terminationFailureOwned.ManifestPath -Raw -Encoding UTF8 | + ConvertFrom-Json -ErrorAction Stop + Assert-True ($terminationFailureManifest.State -ceq 'ACTIVE') ` + 'termination failure did not preserve the ACTIVE manifest' + Assert-True (Test-Path -LiteralPath $terminationFailureOwned.InstallRoot -PathType Container) ` + 'cleanup mutated resources before worker-tree termination was verified' + $terminationRetry = Invoke-WorkflowCleanupController ` + $terminationFailureOwned.ManifestPath $terminationFailureOwned.RunId ` + $terminationFailureStateDirectory + Assert-True ($terminationRetry.ExitCode -eq 0 -and + $terminationRetry.Result -ceq 'COMPLETE') ` + 'termination-failure authority did not retry to exact cleanup success' + Assert-OwnedResourcesGone $terminationFailureOwned + + Assert-True ((Get-Content -LiteralPath (Join-Path $conflictInstallRoot 'pre-existing.txt') -Raw).Trim() -ceq ` + 'owned-before-run') 'pre-existing install tree was removed or changed' + Assert-True ((Get-ItemPropertyValue -LiteralPath $conflictRegistryPath -Name 'PreExisting') -ceq ` + 'owned-before-run') 'pre-existing registry tree was removed or changed' + Assert-True ((Get-Content -LiteralPath $conflictShortcut -Raw).Trim() -ceq ` + 'owned-before-run') 'pre-existing shortcut was removed or changed' + Assert-True ((Get-Content -LiteralPath (Join-Path $conflictSmokeDirectory 'pre-existing.txt') -Raw).Trim() -ceq ` + 'owned-before-run') 'pre-existing smoke data was removed or changed' + $remainingUser = Get-LocalUser -Name $userName -ErrorAction Stop + Assert-True ($remainingUser.SID.Equals($userSid)) 'pre-existing local user was removed or replaced' + $fixtureUserProfiles = @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop | + Where-Object { $_.SID -ceq $userSid.Value }) + Assert-True ($fixtureUserProfiles.Count -eq 0) ` + 'pre-existing local user fixture unexpectedly acquired a profile' + + $gracefulStateDirectory = New-StateDirectory 'graceful-interruption' + $graceful = Start-ExternallyInterruptibleSupervisor $gracefulStateDirectory + try { + $gracefulProcessState = Read-FixtureProcessState $gracefulStateDirectory + $gracefulOwned = Read-FixtureResourceState $gracefulStateDirectory + $graceful.Pipeline.Stop() + try { [void]$graceful.Pipeline.EndInvoke($graceful.AsyncResult) } catch {} + Assert-ProcessTreeGone $gracefulProcessState + Assert-OwnedResourcesGone $gracefulOwned + } finally { + $graceful.Pipeline.Dispose() + } + + $workflowStateDirectory = New-StateDirectory 'workflow-cleanup' + $workflowRunId = [Guid]::NewGuid().ToString('N') + $workflowManifest = Join-Path ([IO.Path]::GetTempPath()) ` + "propr-installed-app-ownership-$workflowRunId.json" + $workflowSupervisor = [Diagnostics.Process]::new() + $workflowSupervisor.StartInfo = New-SupervisorStartInfo ` + 'OWNED_RESOURCES_FOR_INTERRUPTION' $workflowStateDirectory '' $false ` + $workflowManifest $workflowRunId + try { + if (!$workflowSupervisor.Start()) { throw 'workflow supervisor fixture did not start' } + $workflowProcessState = Read-FixtureProcessState $workflowStateDirectory + $workflowOwned = Read-FixtureResourceState $workflowStateDirectory + $workflowSupervisor.Kill($false) + Assert-True ($workflowSupervisor.WaitForExit(5000)) ` + 'killed workflow supervisor did not exit within the bound' + Assert-ProcessTreeGone $workflowProcessState + Assert-True (Test-Path -LiteralPath $workflowManifest -PathType Leaf) ` + 'killed supervisor did not preserve the durable ownership manifest' + $parameterFailure = Invoke-WorkflowCleanupController ` + $workflowManifest $workflowRunId $workflowStateDirectory -1 + Assert-True ($parameterFailure.ExitCode -eq 125 -and + $parameterFailure.Result -ceq 'FAILED' -and + $parameterFailure.ControllerStatus.StartsWith( + 'CONTROLLER_PARAMETER_VALIDATION_PARAMETERS_', + [StringComparison]::Ordinal + )) 'controller parameter failure was not caught and phase-classified' + Assert-True (Test-Path -LiteralPath $workflowManifest -PathType Leaf) ` + 'controller parameter failure discarded authenticated recovery authority' + $earlyInitializationTimeout = Invoke-WorkflowCleanupController ` + $workflowManifest $workflowRunId $workflowStateDirectory 5000 $true + Assert-True ($earlyInitializationTimeout.ExitCode -eq 124 -and + $earlyInitializationTimeout.ReportedExitCode -eq 124 -and + $earlyInitializationTimeout.Result -ceq 'TIMED_OUT') ` + 'early-initialization child cleanup did not report its fixed timeout' + $earlyInitializationState = Get-Content -LiteralPath ` + (Join-Path $workflowStateDirectory 'workflow-cleanup-early-processes.json') ` + -Raw -Encoding ASCII | ConvertFrom-Json -ErrorAction Stop + Assert-ProcessTreeGone $earlyInitializationState + Assert-True (Test-Path -LiteralPath $workflowManifest -PathType Leaf) ` + 'early-initialization timeout discarded authenticated recovery authority' + $timedOutCleanup = Invoke-WorkflowCleanupController ` + $workflowManifest $workflowRunId $workflowStateDirectory 1 + Assert-True ($timedOutCleanup.ExitCode -eq 124 -and + $timedOutCleanup.ReportedExitCode -eq 124 -and + $timedOutCleanup.Result -ceq 'TIMED_OUT') ` + 'workflow cleanup did not report its injected fixed timeout' + Assert-True (Test-Path -LiteralPath $workflowManifest -PathType Leaf) ` + 'timed-out workflow cleanup discarded authenticated recovery authority' + + $installerBackup = Join-Path $testRoot 'fixture-owned-entry.msi' + Move-Item -LiteralPath $dummyInstaller -Destination $installerBackup -ErrorAction Stop + [IO.File]::WriteAllBytes($dummyInstaller, [Text.Encoding]::ASCII.GetBytes( + 'foreign same-path MSI replacement must never be consulted')) + $foreignInstallerDigest = + (Get-FileHash -LiteralPath $dummyInstaller -Algorithm SHA256).Hash + try { + $replacedInstallerCleanup = Invoke-WorkflowCleanupController ` + $workflowManifest $workflowRunId $workflowStateDirectory + Assert-True ($replacedInstallerCleanup.ExitCode -eq 21 -and + $replacedInstallerCleanup.ReportedExitCode -eq 21 -and + $replacedInstallerCleanup.Result -ceq 'FAILED' -and + $replacedInstallerCleanup.ControllerStatus -ceq + 'OWNED_RESOURCE_CLEANUP_FAILURE') ` + 'same-path installer replacement did not fail closed' + Assert-MsiPreflightPreservedResources $workflowOwned + $retainedAuthority = Get-Content -LiteralPath $workflowManifest -Raw -Encoding UTF8 | + ConvertFrom-Json -ErrorAction Stop + Assert-True ($retainedAuthority.State -ceq 'ACTIVE') ` + 'same-path installer replacement discarded ACTIVE recovery authority' + Assert-True ((Get-FileHash -LiteralPath $dummyInstaller -Algorithm SHA256).Hash -ceq + $foreignInstallerDigest) ` + 'foreign same-path installer was executed or changed' + } finally { + if (Test-Path -LiteralPath $dummyInstaller) { + Remove-Item -LiteralPath $dummyInstaller -Force -ErrorAction SilentlyContinue + } + Move-Item -LiteralPath $installerBackup -Destination $dummyInstaller -ErrorAction Stop + } + Assert-True ( + [ProPRSupervisorInstallerIdentity]::Read($dummyInstaller) -ceq + $dummyInstallerEntryIdentity -and + (Get-FileHash -LiteralPath $dummyInstaller -Algorithm SHA256).Hash.ToLowerInvariant() -ceq + $dummyInstallerSha256 + ) 'exact installer authority was not restored for cleanup retry' + + Set-ItemProperty -LiteralPath $workflowOwned.RegistryPath ` + -Name 'ProPRInstalledAppOwner' -Value 'foreign-owner' + $failedWorkflowCleanup = Invoke-WorkflowCleanupController ` + $workflowManifest $workflowRunId $workflowStateDirectory + Assert-True ($failedWorkflowCleanup.ExitCode -eq 21 -and + $failedWorkflowCleanup.ReportedExitCode -eq 21 -and + $failedWorkflowCleanup.Result -ceq 'FAILED' -and + $failedWorkflowCleanup.ControllerStatus -ceq 'OWNED_RESOURCE_CLEANUP_FAILURE') ` + 'workflow cleanup did not report a fixed replacement-collision failure' + Assert-True ((Get-ItemPropertyValue -LiteralPath $workflowOwned.RegistryPath ` + -Name 'ProPRInstalledAppOwner') -ceq 'foreign-owner') ` + 'workflow cleanup removed a replacement registry object' + Assert-True (Test-Path -LiteralPath $workflowManifest -PathType Leaf) ` + 'failed workflow cleanup discarded authenticated recovery authority' + + Set-ItemProperty -LiteralPath $workflowOwned.RegistryPath ` + -Name 'ProPRInstalledAppOwner' -Value ([string]$workflowOwned.Token) + $workflowCleanup = Invoke-WorkflowCleanupController ` + $workflowManifest $workflowRunId $workflowStateDirectory + Assert-True ($workflowCleanup.ExitCode -eq 0 -and + $workflowCleanup.ReportedExitCode -eq 0 -and + $workflowCleanup.ControllerStatus -ceq 'EMPTY_OR_CLEANED') ` + 'workflow cleanup controller did not retry to fixed cleanup success' + Assert-Contains $workflowCleanup.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:COMPLETE' ` + 'workflow cleanup controller did not emit fixed completion evidence' + Assert-OwnedResourcesGone $workflowOwned + Assert-True (!(Test-Path -LiteralPath $workflowManifest)) ` + 'workflow cleanup did not consume the ownership manifest' + } finally { + if (!$workflowSupervisor.HasExited) { try { $workflowSupervisor.Kill($true) } catch {} } + $workflowSupervisor.Dispose() + } + + $normalStateDirectory = New-StateDirectory 'workflow-normal-already-cleaned' + $normalRunId = [Guid]::NewGuid().ToString('N') + $normalManifest = Join-Path ([IO.Path]::GetTempPath()) ` + "propr-installed-app-ownership-$normalRunId.json" + $normalSupervisor = [Diagnostics.Process]::new() + $normalSupervisor.StartInfo = New-SupervisorStartInfo ` + 'OWNED_RESOURCES_NORMAL_SUCCESS' $normalStateDirectory '' $false ` + $normalManifest $normalRunId + try { + if (!$normalSupervisor.Start()) { throw 'normal workflow supervisor fixture did not start' } + $normalOwned = Read-FixtureResourceState $normalStateDirectory + Assert-True ($normalSupervisor.WaitForExit(40000)) ` + 'normal workflow supervisor fixture exceeded its bound' + Assert-True ($normalSupervisor.ExitCode -eq 0) ` + 'normal workflow supervisor fixture did not complete successfully' + Assert-OwnedResourcesGone $normalOwned + Assert-True (Test-Path -LiteralPath $normalManifest -PathType Leaf) ` + 'normal supervisor did not preserve its empty ownership receipt' + $normalReceipt = Get-Content -LiteralPath $normalManifest -Raw -Encoding UTF8 | + ConvertFrom-Json -ErrorAction Stop + Assert-True ($normalReceipt.SchemaVersion -eq 3 -and + $normalReceipt.ManifestType -ceq 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP' -and + $normalReceipt.State -ceq 'EMPTY' -and + $normalReceipt.InstallerEntryIdentity -ceq $dummyInstallerEntryIdentity -and + $normalReceipt.InstallerSha256 -ceq $dummyInstallerSha256 -and + $normalReceipt.InstallerProductCode -ceq $dummyInstallerProductCode -and + @($normalReceipt.Directories).Count -eq 0 -and + @($normalReceipt.Files).Count -eq 0 -and + @($normalReceipt.RegistryKeys).Count -eq 0 -and + @($normalReceipt.RegistryValues).Count -eq 0 -and + @($normalReceipt.Users).Count -eq 0 -and + @($normalReceipt.Profiles).Count -eq 0) ` + 'normal supervisor did not produce a typed authenticated empty-state receipt' + $normalCleanup = Invoke-WorkflowCleanupController ` + $normalManifest $normalRunId $normalStateDirectory + Assert-True ($normalCleanup.ExitCode -eq 0 -and + $normalCleanup.ReportedExitCode -eq 0 -and + $normalCleanup.ControllerStatus -ceq 'EMPTY_OR_CLEANED') ` + 'always cleanup did not accept the normal already-cleaned receipt' + Assert-True (!(Test-Path -LiteralPath $normalManifest)) ` + 'always cleanup did not consume the normal empty-state receipt' + } finally { + if (!$normalSupervisor.HasExited) { try { $normalSupervisor.Kill($true) } catch {} } + $normalSupervisor.Dispose() + } + + foreach ($manifestCase in @('MISSING','MALFORMED','STALE')) { + $badRunId = [Guid]::NewGuid().ToString('N') + $badManifest = Join-Path ([IO.Path]::GetTempPath()) ` + "propr-installed-app-ownership-$badRunId.json" + if ($manifestCase -eq 'MALFORMED') { + [IO.File]::WriteAllText($badManifest, '{not-json', [Text.Encoding]::UTF8) + } elseif ($manifestCase -eq 'STALE') { + $createdTicks = [DateTime]::UtcNow.AddHours(-4).Ticks + $staleManifest = [ordered]@{ + SchemaVersion = 3 + ManifestType = 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP'; State = 'ACTIVE' + RunId = $badRunId + CreatedUtcTicks = $createdTicks + ExpiresUtcTicks = $createdTicks + ([TimeSpan]::TicksPerHour * 3) + InstallerPath = $dummyInstaller + InstallerEntryIdentity = $dummyInstallerEntryIdentity + InstallerSha256 = $dummyInstallerSha256 + InstallerProductCode = $dummyInstallerProductCode + Fixture = $true + FixtureRoot = $workflowStateDirectory; BaselineClean = $false + InstallAttempted = $false; MsiTransactionState = 'NONE' + Directories = @(); Files = @() + RegistryKeys = @(); RegistryValues = @(); Users = @(); Profiles = @() + } + [IO.File]::WriteAllText( + $badManifest, + ($staleManifest | ConvertTo-Json -Depth 6 -Compress), + [Text.Encoding]::UTF8 + ) + } + $failedCleanup = Invoke-WorkflowCleanupController ` + $badManifest $badRunId $workflowStateDirectory + Assert-True ($failedCleanup.ExitCode -ne 0) ` + "$manifestCase workflow manifest did not fail closed" + Assert-True ($failedCleanup.ExitCode -eq 20 -and + $failedCleanup.ReportedExitCode -eq 20 -and + $failedCleanup.ControllerStatus -ceq 'MANIFEST_VALIDATION_FAILURE') ` + "$manifestCase workflow manifest did not report fixed validation status" + Assert-Contains $failedCleanup.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:FAILED' ` + "$manifestCase workflow manifest did not emit fixed failure evidence" + if ($manifestCase -ne 'MISSING') { + Assert-True (Test-Path -LiteralPath $badManifest -PathType Leaf) ` + "$manifestCase workflow failure discarded authenticated recovery authority" + Remove-Item -LiteralPath $badManifest -Force -ErrorAction Stop + } + } + + Assert-True ((Get-Content -LiteralPath (Join-Path $conflictInstallRoot 'pre-existing.txt') -Raw).Trim() -ceq ` + 'owned-before-run') 'external cleanup changed the pre-existing install tree' + Assert-True ((Get-ItemPropertyValue -LiteralPath $conflictRegistryPath -Name 'PreExisting') -ceq ` + 'owned-before-run') 'external cleanup changed the pre-existing registry tree' + Assert-True ((Get-Content -LiteralPath $conflictShortcut -Raw).Trim() -ceq ` + 'owned-before-run') 'external cleanup changed the pre-existing shortcut' + Assert-True ((Get-LocalUser -Name $userName -ErrorAction Stop).SID.Equals($userSid)) ` + 'external cleanup changed the pre-existing local user' + } finally { + $script:conflictingFixtureUserName = $null + $script:conflictingFixtureUserSid = $null + $script:conflictingFixtureProfileSid = $null + $script:conflictingFixtureProfilePath = $null + $script:conflictingFixtureDirectories = $null + $script:conflictingFixtureShortcut = $null + $script:conflictingFixtureRegistryPath = $null + if ($registryCreated -and (Test-Path -LiteralPath $conflictRegistryPath)) { + Remove-Item -LiteralPath $conflictRegistryPath -Recurse -Force -ErrorAction SilentlyContinue + } + $fixtureRegistryRoot = 'Registry::HKEY_LOCAL_MACHINE\Software\ProPRSupervisorFixture' + if ((Test-Path -LiteralPath $fixtureRegistryRoot) -and + @(Get-ChildItem -LiteralPath $fixtureRegistryRoot -Force -ErrorAction SilentlyContinue).Count -eq 0) { + Remove-Item -LiteralPath $fixtureRegistryRoot -Force -ErrorAction SilentlyContinue + } + if ($userCreated) { + $ownedUser = Get-LocalUser -Name $userName -ErrorAction SilentlyContinue + if ($null -ne $ownedUser) { + Assert-True ($null -ne $userSid -and $ownedUser.SID.Equals($userSid)) ` + 'refusing to remove a local user not owned by the fixture' + Remove-LocalUser -Name $userName -ErrorAction Stop + Assert-True ($null -eq (Get-LocalUser -Name $userName -ErrorAction SilentlyContinue)) ` + 'ownership local-user fixture cleanup failed' + } + } + $ownedUser = Get-LocalUser -Name $ownedFixtureUserName -ErrorAction SilentlyContinue + if ($null -ne $ownedUser) { + $ownedProfiles = @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction SilentlyContinue | + Where-Object { $_.SID -ceq $ownedUser.SID.Value }) + foreach ($profile in $ownedProfiles) { + Remove-CimInstance -InputObject $profile -ErrorAction SilentlyContinue + } + Remove-LocalUser -Name $ownedFixtureUserName -ErrorAction SilentlyContinue + } + Assert-RunnerProfileUnchanged $runnerProfileBefore + } + Write-Host 'PROPR_WINDOWS_SUPERVISOR_OWNERSHIP:PRE_EXISTING_AUTHORITIES:PRESERVED' + [Console]::Out.Flush() +} + +function Test-SmokePromotionInterruptionAuthority { + foreach ($testCase in @( + @{ Scenario = 'SMOKE_BEFORE_PROMOTION_THEN_DEADLINE'; Label = 'before promotion' }, + @{ Scenario = 'SMOKE_AFTER_PROMOTION_THEN_DEADLINE'; Label = 'after promotion' }, + @{ Scenario = 'SMOKE_AFTER_ARTIFACTS_THEN_DEADLINE'; Label = 'after artifact creation' } + )) { + $stateDirectory = New-StateDirectory ( + 'smoke-' + $testCase.Scenario.ToLowerInvariant().Replace('_', '-')) + $result = Invoke-FixtureScenario $testCase.Scenario $stateDirectory + Assert-True ($result.ExitCode -eq 124) ` + "smoke interruption $($testCase.Label) did not preserve watchdog status" + Assert-Contains $result.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:COMPLETE' ` + "smoke interruption $($testCase.Label) did not complete recovery cleanup" + $owned = Read-FixtureResourceState $stateDirectory + Assert-OwnedResourcesGone $owned + } + + $foreignStateDirectory = New-StateDirectory 'smoke-in-place-foreign-descendant' + $foreignResult = Invoke-FixtureScenario ` + 'SMOKE_FOREIGN_DESCENDANT_THEN_DEADLINE' $foreignStateDirectory + Assert-True ($foreignResult.ExitCode -eq 125) ` + 'smoke foreign descendant did not fail closed' + Assert-Contains $foreignResult.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' ` + 'smoke foreign descendant did not emit fixed cleanup failure evidence' + $foreignOwned = Read-FixtureResourceState $foreignStateDirectory + Assert-True ((Get-Content -LiteralPath $foreignOwned.ForeignSmokePath -Raw).Trim() -ceq ` + 'foreign-smoke-in-place') 'smoke foreign descendant was removed or changed' + Assert-True (Test-Path -LiteralPath $foreignOwned.ManifestPath -PathType Leaf) ` + 'smoke foreign descendant discarded authenticated recovery authority' + $foreignManifest = Get-Content -LiteralPath $foreignOwned.ManifestPath -Raw -Encoding UTF8 | + ConvertFrom-Json -ErrorAction Stop + Assert-True ($foreignManifest.State -ceq 'ACTIVE') ` + 'smoke foreign descendant did not preserve ACTIVE recovery authority' + Remove-Item -LiteralPath $foreignOwned.ForeignSmokePath -Force -ErrorAction Stop + $retry = Invoke-WorkflowCleanupController ` + $foreignOwned.ManifestPath $foreignOwned.RunId $foreignStateDirectory + Assert-True ($retry.ExitCode -eq 0 -and $retry.Result -ceq 'COMPLETE') ` + 'smoke foreign-descendant recovery did not retry to exact success' + Assert-OwnedResourcesGone $foreignOwned + + $tokenStateDirectory = New-StateDirectory 'smoke-token-mismatch' + $tokenResult = Invoke-FixtureScenario ` + 'SMOKE_TOKEN_MISMATCH_THEN_DEADLINE' $tokenStateDirectory + Assert-True ($tokenResult.ExitCode -eq 125) ` + 'mismatched smoke ownership token did not fail closed' + $tokenOwned = Read-FixtureResourceState $tokenStateDirectory + $tokenPath = Join-Path $tokenOwned.SmokeDirectory '.propr-installed-app-owner' + Assert-True ((Get-Content -LiteralPath $tokenPath -Raw).Trim() -ceq 'foreign-owner') ` + 'mismatched smoke ownership token was removed or changed' + Assert-True (Test-Path -LiteralPath $tokenOwned.ManifestPath -PathType Leaf) ` + 'mismatched smoke ownership token discarded recovery authority' + Remove-Item -LiteralPath $tokenPath -Force -ErrorAction Stop + $missingToken = Invoke-WorkflowCleanupController ` + $tokenOwned.ManifestPath $tokenOwned.RunId $tokenStateDirectory + Assert-True ($missingToken.ExitCode -eq 20 -and $missingToken.Result -ceq 'FAILED') ` + 'missing smoke ownership token did not fail manifest validation closed' + Assert-True (Test-Path -LiteralPath $tokenOwned.ManifestPath -PathType Leaf) ` + 'missing smoke ownership token discarded recovery authority' + [IO.File]::WriteAllText($tokenPath, [string]$tokenOwned.Token, [Text.Encoding]::ASCII) + $tokenRetry = Invoke-WorkflowCleanupController ` + $tokenOwned.ManifestPath $tokenOwned.RunId $tokenStateDirectory + Assert-True ($tokenRetry.ExitCode -eq 0 -and $tokenRetry.Result -ceq 'COMPLETE') ` + 'restored exact smoke ownership token did not retry to cleanup success' + Assert-OwnedResourcesGone $tokenOwned +} + +function Test-PrimaryWorkerFallbackForeignDescendants { + $stateDirectory = New-StateDirectory 'primary-fallback-foreign-descendants' + $result = Invoke-FixtureScenario 'PRIMARY_FALLBACK_FOREIGN_DESCENDANTS' $stateDirectory + $diagnostic = Get-SanitizedSupervisorMarkerDiagnostic $result + Assert-True ($result.ExitCode -eq 0) ` + "primary worker fallback foreign-descendant fixture did not complete:$diagnostic" + $state = Get-Content -LiteralPath (Join-Path $stateDirectory 'primary-fallback.json') ` + -Raw -Encoding ASCII | ConvertFrom-Json -ErrorAction Stop + Assert-True ((Get-Content -LiteralPath $state.InstallForeign -Raw).Trim() -ceq ` + 'foreign-install') 'primary install fallback removed or changed a foreign descendant' + Assert-True ((Get-Content -LiteralPath $state.ShortcutForeign -Raw).Trim() -ceq ` + 'foreign-shortcut') 'primary shortcut fallback removed or changed a foreign descendant' +} + +function Test-PreExistingAppPathsAuthority { + $appPaths = ` + 'Registry::HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\CurrentVersion\App Paths\propr-desktop.exe' + $protocol = 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr' + $sentinelApplication = 'C:\pre-existing\propr-desktop.exe' + $sentinelProtocol = 'pre-existing-protocol' + Assert-True (!(Test-Path -LiteralPath $appPaths)) ` + 'pre-existing App Paths fixture baseline was not clean' + Assert-True (!(Test-Path -LiteralPath $protocol)) ` + 'pre-existing protocol fixture baseline was not clean' + try { + [void](New-Item -Path $appPaths -Force -ErrorAction Stop) + Set-Item -LiteralPath $appPaths -Value $sentinelApplication + Set-ItemProperty -LiteralPath $appPaths -Name 'Path' -Value 'C:\pre-existing' + [void](New-Item -Path $protocol -Force -ErrorAction Stop) + Set-Item -LiteralPath $protocol -Value $sentinelProtocol + Set-ItemProperty -LiteralPath $protocol -Name 'URL Protocol' -Value 'do-not-remove' + + $process = [Diagnostics.Process]::new() + $process.StartInfo = New-SupervisorStartInfo ` + 'PRE_EXISTING_APP_PATHS' $testRoot '' $true + try { + if (!$process.Start()) { throw 'pre-existing registry supervisor did not start' } + Assert-True ($process.WaitForExit(20000)) ` + 'pre-existing registry supervisor exceeded its bound' + $output = $process.StandardOutput.ReadToEnd() + $errorOutput = $process.StandardError.ReadToEnd() + Assert-True ($process.ExitCode -ne 0) ` + 'pre-existing App Paths authority was not rejected' + Assert-Contains $output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:COMPLETE' ` + 'pre-existing App Paths rejection did not finish bounded cleanup' + Assert-NotContains "$output`n$errorOutput" $sentinelApplication ` + 'pre-existing App Paths evidence was not redacted' + } finally { + if (!$process.HasExited) { try { $process.Kill($true) } catch {} } + $process.Dispose() + } + Assert-True ((Get-Item -LiteralPath $appPaths).GetValue('') -ceq $sentinelApplication) ` + 'pre-existing App Paths executable was removed or changed' + Assert-True ((Get-Item -LiteralPath $appPaths).GetValue('Path') -ceq 'C:\pre-existing') ` + 'pre-existing App Paths values were removed or changed' + Assert-True ((Get-Item -LiteralPath $protocol).GetValue('') -ceq $sentinelProtocol) ` + 'pre-existing protocol key was removed or changed' + Assert-True ((Get-Item -LiteralPath $protocol).GetValue('URL Protocol') -ceq 'do-not-remove') ` + 'pre-existing protocol values were removed or changed' + + $mismatchRunId = [Guid]::NewGuid().ToString('N') + $mismatchManifest = Join-Path ([IO.Path]::GetTempPath()) ` + "propr-installed-app-ownership-$mismatchRunId.json" + $createdTicks = [DateTime]::UtcNow.Ticks + $mismatchState = [ordered]@{ + SchemaVersion = 3 + ManifestType = 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP'; State = 'ACTIVE' + RunId = $mismatchRunId + CreatedUtcTicks = $createdTicks + ExpiresUtcTicks = $createdTicks + ([TimeSpan]::TicksPerHour * 3) + InstallerPath = $dummyInstaller + InstallerEntryIdentity = $dummyInstallerEntryIdentity + InstallerSha256 = $dummyInstallerSha256 + InstallerProductCode = $dummyInstallerProductCode + Fixture = $false; FixtureRoot = $null + BaselineClean = $true; InstallAttempted = $true + MsiTransactionState = 'COMMITTED' + Directories = @(); Files = @(); Users = @(); Profiles = @() + RegistryValues = @([ordered]@{ + Kind = 'HKCU_INSTALLED' + Path = 'Registry::HKEY_CURRENT_USER\Software\ProPR\Desktop' + Name = 'installed'; Owned = $false; Provisional = $false + BaselineKeyExisted = $false; BaselineValueExisted = $false + BaselineValueKind = $null; BaselineValueData = $null + IdentityValueKind = $null; IdentityValueData = $null; KeyCreatedByRun = $false + }) + RegistryKeys = @( + [ordered]@{ + Kind = 'PROTOCOL'; Path = $protocol; Owned = $true; Token = $null + Identity = ('0' * 64); Provisional = $false + }, + [ordered]@{ + Kind = 'APP_PATH'; Path = $appPaths; Owned = $true; Token = $null + Identity = ('0' * 64); Provisional = $false + } + ) + } + [IO.File]::WriteAllText( + $mismatchManifest, + ($mismatchState | ConvertTo-Json -Depth 6 -Compress), + [Text.Encoding]::UTF8 + ) + $mismatchCleanup = Invoke-WorkflowCleanupController ` + $mismatchManifest $mismatchRunId '' + Assert-True ($mismatchCleanup.ExitCode -ne 0) ` + 'mismatched App Paths ownership identity did not fail closed' + Assert-True ($mismatchCleanup.ExitCode -eq 20 -and + $mismatchCleanup.ReportedExitCode -eq 20 -and + $mismatchCleanup.ControllerStatus -ceq 'MANIFEST_VALIDATION_FAILURE') ` + 'mismatched App Paths ownership did not report fixed validation status' + Assert-Contains $mismatchCleanup.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:FAILED' ` + 'mismatched App Paths ownership did not emit fixed failure evidence' + Assert-True ((Get-Item -LiteralPath $appPaths).GetValue('') -ceq $sentinelApplication) ` + 'mismatched App Paths ownership removed the pre-existing executable value' + Assert-True ((Get-Item -LiteralPath $appPaths).GetValue('Path') -ceq 'C:\pre-existing') ` + 'mismatched App Paths ownership removed pre-existing values' + Assert-True ((Get-Item -LiteralPath $protocol).GetValue('') -ceq $sentinelProtocol) ` + 'mismatched protocol ownership removed the pre-existing key' + Assert-True ((Get-Item -LiteralPath $protocol).GetValue('URL Protocol') -ceq 'do-not-remove') ` + 'mismatched protocol ownership removed pre-existing values' + } finally { + if ((Test-Path -LiteralPath $appPaths) -and + (Get-Item -LiteralPath $appPaths).GetValue('') -ceq $sentinelApplication) { + Remove-Item -LiteralPath $appPaths -Recurse -Force -ErrorAction SilentlyContinue + } + if ((Test-Path -LiteralPath $protocol) -and + (Get-Item -LiteralPath $protocol).GetValue('') -ceq $sentinelProtocol) { + Remove-Item -LiteralPath $protocol -Recurse -Force -ErrorAction SilentlyContinue + } + } + Write-Host 'PROPR_WINDOWS_SUPERVISOR_OWNERSHIP:APP_PATHS_PRE_EXISTING:PRESERVED' + [Console]::Out.Flush() +} + +function Test-HkcuInstalledValueOwnership { + $desktopKey = 'Registry::HKEY_CURRENT_USER\Software\ProPR\Desktop' + $installedName = 'installed' + $sentinelInstalled = 'pre-existing-installed' + $sentinelUnrelated = 'preserve-unrelated' + Assert-True (!(Test-Path -LiteralPath $desktopKey)) ` + 'HKCU installed-value fixture baseline was not clean' + + function New-HkcuManifest( + [bool]$BaselineKeyExisted, + [bool]$BaselineValueExisted, + [AllowNull()][string]$BaselineKind, + [AllowNull()][string]$BaselineData, + [bool]$KeyCreatedByRun, + [bool]$Provisional = $false, + [bool]$InstallAttempted = $false + ) { + $runId = [Guid]::NewGuid().ToString('N') + $path = Join-Path ([IO.Path]::GetTempPath()) ` + "propr-installed-app-ownership-$runId.json" + $createdTicks = [DateTime]::UtcNow.Ticks + $installedIdentityData = [Convert]::ToBase64String( + [BitConverter]::GetBytes([int32]1)) + $manifest = [ordered]@{ + SchemaVersion = 3 + ManifestType = 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP' + State = 'ACTIVE' + RunId = $runId + CreatedUtcTicks = $createdTicks + ExpiresUtcTicks = $createdTicks + ([TimeSpan]::TicksPerHour * 3) + InstallerPath = $dummyInstaller + InstallerEntryIdentity = $dummyInstallerEntryIdentity + InstallerSha256 = $dummyInstallerSha256 + InstallerProductCode = $dummyInstallerProductCode + Fixture = $false + FixtureRoot = $null + BaselineClean = $InstallAttempted + InstallAttempted = $InstallAttempted + MsiTransactionState = if ($InstallAttempted) { 'PENDING' } else { 'NONE' } + Directories = @() + Files = @() + RegistryKeys = @() + RegistryValues = @([ordered]@{ + Kind = 'HKCU_INSTALLED'; Path = $desktopKey; Name = $installedName + Owned = $true; Provisional = $Provisional + BaselineKeyExisted = $BaselineKeyExisted + BaselineValueExisted = $BaselineValueExisted + BaselineValueKind = $BaselineKind + BaselineValueData = $BaselineData + IdentityValueKind = if ($Provisional) { $null } else { 'DWord' } + IdentityValueData = if ($Provisional) { $null } else { $installedIdentityData } + KeyCreatedByRun = $KeyCreatedByRun + }) + Users = @() + Profiles = @() + } + [IO.File]::WriteAllText( + $path, + ($manifest | ConvertTo-Json -Depth 6 -Compress), + [Text.Encoding]::UTF8 + ) + return [PSCustomObject]@{ RunId = $runId; Path = $path } + } + + try { + [void](New-Item -Path $desktopKey -Force -ErrorAction Stop) + (Get-Item -LiteralPath $desktopKey).SetValue( + $installedName, $sentinelInstalled, [Microsoft.Win32.RegistryValueKind]::String) + (Get-Item -LiteralPath $desktopKey).SetValue( + 'Unrelated', $sentinelUnrelated, [Microsoft.Win32.RegistryValueKind]::String) + $baselineData = [Convert]::ToBase64String( + [Text.Encoding]::UTF8.GetBytes($sentinelInstalled)) + (Get-Item -LiteralPath $desktopKey).SetValue( + $installedName, [int]1, [Microsoft.Win32.RegistryValueKind]::DWord) + $restoreManifest = New-HkcuManifest $true $true 'String' $baselineData $false + $restore = Invoke-WorkflowCleanupController $restoreManifest.Path $restoreManifest.RunId '' + Assert-True ($restore.ExitCode -eq 0 -and + $restore.ControllerStatus -ceq 'EMPTY_OR_CLEANED') ` + 'pre-existing HKCU installed value restoration did not complete' + $restoredKey = Get-Item -LiteralPath $desktopKey -ErrorAction Stop + Assert-True ($restoredKey.GetValueKind($installedName).ToString() -ceq 'String' -and + [string]$restoredKey.GetValue($installedName) -ceq $sentinelInstalled) ` + 'pre-existing HKCU installed value was not restored exactly' + Assert-True ([string]$restoredKey.GetValue('Unrelated') -ceq $sentinelUnrelated) ` + 'unrelated HKCU value was changed during baseline restoration' + + $unchangedManifest = New-HkcuManifest ` + $true $true 'String' $baselineData $false $false $true + $unchanged = Invoke-WorkflowCleanupController ` + $unchangedManifest.Path $unchangedManifest.RunId '' + Assert-True ($unchanged.ExitCode -eq 21 -and + $unchanged.ControllerStatus -ceq 'OWNED_RESOURCE_CLEANUP_FAILURE') ` + 'path-only pending MSI receipt was not rejected before uninstall' + $unchangedKey = Get-Item -LiteralPath $desktopKey -ErrorAction Stop + Assert-True ($unchangedKey.GetValueKind($installedName).ToString() -ceq 'String' -and + [string]$unchangedKey.GetValue($installedName) -ceq $sentinelInstalled) ` + 'rejected pending MSI receipt changed the unchanged HKCU baseline' + Assert-True (Test-Path -LiteralPath $unchangedManifest.Path -PathType Leaf) ` + 'rejected pending MSI receipt discarded authenticated recovery authority' + Remove-Item -LiteralPath $unchangedManifest.Path -Force -ErrorAction Stop + + Remove-Item -LiteralPath $desktopKey -Recurse -Force -ErrorAction Stop + [void](New-Item -Path $desktopKey -Force -ErrorAction Stop) + (Get-Item -LiteralPath $desktopKey).SetValue( + $installedName, [int]1, [Microsoft.Win32.RegistryValueKind]::DWord) + (Get-Item -LiteralPath $desktopKey).SetValue( + 'Unrelated', $sentinelUnrelated, [Microsoft.Win32.RegistryValueKind]::String) + $nonemptyManifest = New-HkcuManifest $false $false $null $null $true + $nonempty = Invoke-WorkflowCleanupController $nonemptyManifest.Path $nonemptyManifest.RunId '' + Assert-True ($nonempty.ExitCode -eq 0) ` + 'run-owned HKCU value cleanup with unrelated values failed' + $nonemptyKey = Get-Item -LiteralPath $desktopKey -ErrorAction Stop + Assert-True (@($nonemptyKey.GetValueNames()) -cnotcontains $installedName -and + [string]$nonemptyKey.GetValue('Unrelated') -ceq $sentinelUnrelated) ` + 'run-owned HKCU cleanup removed its nonempty key or unrelated value' + + Remove-Item -LiteralPath $desktopKey -Recurse -Force -ErrorAction Stop + [void](New-Item -Path $desktopKey -Force -ErrorAction Stop) + (Get-Item -LiteralPath $desktopKey).SetValue( + $installedName, [int]1, [Microsoft.Win32.RegistryValueKind]::DWord) + $emptyManifest = New-HkcuManifest $false $false $null $null $true + $empty = Invoke-WorkflowCleanupController $emptyManifest.Path $emptyManifest.RunId '' + Assert-True ($empty.ExitCode -eq 0 -and !(Test-Path -LiteralPath $desktopKey)) ` + 'run-created empty HKCU key was not removed' + + [void](New-Item -Path $desktopKey -Force -ErrorAction Stop) + (Get-Item -LiteralPath $desktopKey).SetValue( + $installedName, 'foreign-conflict', [Microsoft.Win32.RegistryValueKind]::String) + $conflictManifest = New-HkcuManifest $false $false $null $null $true + $conflict = Invoke-WorkflowCleanupController ` + $conflictManifest.Path $conflictManifest.RunId '' + Assert-True ($conflict.ExitCode -eq 21 -and + $conflict.ReportedExitCode -eq 21 -and + $conflict.ControllerStatus -ceq 'OWNED_RESOURCE_CLEANUP_FAILURE') ` + 'conflicting HKCU installed value did not fail with fixed resource-cleanup status' + $conflictingKey = Get-Item -LiteralPath $desktopKey -ErrorAction Stop + Assert-True ([string]$conflictingKey.GetValue($installedName) -ceq 'foreign-conflict') ` + 'conflicting HKCU installed value was removed or changed' + Assert-True (Test-Path -LiteralPath $conflictManifest.Path -PathType Leaf) ` + 'conflicting HKCU cleanup discarded authenticated recovery authority' + Remove-Item -LiteralPath $conflictManifest.Path -Force -ErrorAction Stop + + Remove-Item -LiteralPath $desktopKey -Recurse -Force -ErrorAction Stop + [void](New-Item -Path $desktopKey -Force -ErrorAction Stop) + (Get-Item -LiteralPath $desktopKey).SetValue( + $installedName, [int]1, [Microsoft.Win32.RegistryValueKind]::DWord) + $provisionalManifest = New-HkcuManifest $false $false $null $null $true $true + $provisional = Invoke-WorkflowCleanupController ` + $provisionalManifest.Path $provisionalManifest.RunId '' + Assert-True ($provisional.ExitCode -eq 21 -and + $provisional.ControllerStatus -ceq 'OWNED_RESOURCE_CLEANUP_FAILURE') ` + 'provisional HKCU evidence authorized manual registry deletion' + Assert-True ((Get-Item -LiteralPath $desktopKey).GetValueKind($installedName).ToString() ` + -ceq 'DWord' -and + [int](Get-ItemPropertyValue -LiteralPath $desktopKey -Name $installedName) -eq 1) ` + 'provisional HKCU installed value was removed or changed' + Assert-True (Test-Path -LiteralPath $provisionalManifest.Path -PathType Leaf) ` + 'provisional HKCU failure discarded authenticated recovery authority' + Remove-Item -LiteralPath $provisionalManifest.Path -Force -ErrorAction Stop + } finally { + if (Test-Path -LiteralPath $desktopKey) { + Remove-Item -LiteralPath $desktopKey -Recurse -Force -ErrorAction SilentlyContinue + } + } + Write-Host 'PROPR_WINDOWS_SUPERVISOR_OWNERSHIP:HKCU_INSTALLED_VALUE:PRESERVED' + [Console]::Out.Flush() +} + +function Test-ProvisionalUserMarkerOwnership { + function New-ProvisionalUserManifest([string]$UserName, [string]$OwnershipMarker) { + $runId = [Guid]::NewGuid().ToString('N') + $path = Join-Path ([IO.Path]::GetTempPath()) ` + "propr-installed-app-ownership-$runId.json" + $createdTicks = [DateTime]::UtcNow.Ticks + $manifest = [ordered]@{ + SchemaVersion = 3 + ManifestType = 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP' + State = 'ACTIVE' + RunId = $runId + CreatedUtcTicks = $createdTicks + ExpiresUtcTicks = $createdTicks + ([TimeSpan]::TicksPerHour * 3) + InstallerPath = $dummyInstaller + InstallerEntryIdentity = $dummyInstallerEntryIdentity + InstallerSha256 = $dummyInstallerSha256 + InstallerProductCode = $dummyInstallerProductCode + Fixture = $true + FixtureRoot = $testRoot + BaselineClean = $false + InstallAttempted = $false + MsiTransactionState = 'NONE' + Directories = @() + Files = @() + RegistryKeys = @() + RegistryValues = @() + Users = @([ordered]@{ + Name = $UserName + Sid = $null + Owned = $true + Provisional = $true + OwnershipMarker = $OwnershipMarker + }) + Profiles = @() + } + [IO.File]::WriteAllText( + $path, + ($manifest | ConvertTo-Json -Depth 6 -Compress), + [Text.Encoding]::UTF8 + ) + return [PSCustomObject]@{ RunId = $runId; Path = $path } + } + + $password = ConvertTo-SecureString "P!$([Guid]::NewGuid().ToString('N'))u8" ` + -AsPlainText -Force + $positiveName = "prpr$([Guid]::NewGuid().ToString('N').Substring(0,8))" + $positiveMarker = "prpr-own-$([Guid]::NewGuid().ToString('N'))" + $replacementName = "prpr$([Guid]::NewGuid().ToString('N').Substring(0,8))" + $replacementMarker = "prpr-own-$([Guid]::NewGuid().ToString('N'))" + $positiveManifest = $null + $replacementManifest = $null + try { + $positiveManifest = New-ProvisionalUserManifest $positiveName $positiveMarker + New-LocalUser -Name $positiveName -Password $password ` + -Description $positiveMarker -AccountNeverExpires -PasswordNeverExpires | Out-Null + $positive = Invoke-WorkflowCleanupController ` + $positiveManifest.Path $positiveManifest.RunId $testRoot + Assert-True ($positive.ExitCode -eq 0 -and + $positive.Result -ceq 'COMPLETE') ` + 'marker-bound provisional local-user recovery did not complete' + Assert-True ($null -eq (Get-LocalUser -Name $positiveName -ErrorAction SilentlyContinue)) ` + 'marker-bound provisional local-user recovery left its account behind' + + $replacementManifest = New-ProvisionalUserManifest $replacementName $replacementMarker + New-LocalUser -Name $replacementName -Password $password ` + -Description "prpr-own-$([Guid]::NewGuid().ToString('N'))" ` + -AccountNeverExpires -PasswordNeverExpires | Out-Null + $replacementSid = (Get-LocalUser -Name $replacementName -ErrorAction Stop).SID.Value + $replacement = Invoke-WorkflowCleanupController ` + $replacementManifest.Path $replacementManifest.RunId $testRoot + Assert-True ($replacement.ExitCode -eq 21 -and + $replacement.ControllerStatus -ceq 'OWNED_RESOURCE_CLEANUP_FAILURE') ` + 'provisional username authorized replacement-account deletion' + $survivingReplacement = Get-LocalUser -Name $replacementName -ErrorAction Stop + Assert-True ($survivingReplacement.SID.Value -ceq $replacementSid) ` + 'replacement account identity changed during provisional cleanup' + Assert-True (Test-Path -LiteralPath $replacementManifest.Path -PathType Leaf) ` + 'provisional replacement failure discarded authenticated recovery authority' + $replacementAuthority = Get-Content -LiteralPath $replacementManifest.Path ` + -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop + Assert-True ($replacementAuthority.State -ceq 'ACTIVE') ` + 'provisional replacement failure did not preserve the ACTIVE manifest' + } finally { + foreach ($name in @($positiveName, $replacementName)) { + $user = Get-LocalUser -Name $name -ErrorAction SilentlyContinue + if ($null -ne $user) { Remove-LocalUser -Name $name -ErrorAction SilentlyContinue } + } + foreach ($manifest in @($positiveManifest, $replacementManifest)) { + if ($null -ne $manifest -and (Test-Path -LiteralPath $manifest.Path)) { + Remove-Item -LiteralPath $manifest.Path -Force -ErrorAction SilentlyContinue + } + } + } + Write-Host 'PROPR_WINDOWS_SUPERVISOR_OWNERSHIP:PROVISIONAL_USER_MARKER:PRESERVED' + [Console]::Out.Flush() +} + +if (![OperatingSystem]::IsWindows()) { throw 'supervisor behavior tests require Windows' } +$actualArchitecture = [Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString().ToLowerInvariant() +Assert-True ($actualArchitecture -ceq $Architecture) ` + "supervisor behavior tests expected $Architecture but are running on $actualArchitecture" + +Test-WorkflowCleanupBodyParserRegression +[void](New-Item -ItemType Directory -Path $testRoot -ErrorAction Stop) +Initialize-TestInstaller +try { + Test-WorkflowCleanupStartupProtocol + Test-BootstrapTimeout + Test-WindowsPowerShellCleanupCompatibility + Test-OperationDeadlineAndTreeTermination + Test-NegativeWorkerExitFinalization + Test-FailClosedMarkers + Test-LiveCancellationAndRedaction + Test-MsiTransactionInterruptionGates + Test-PrimaryWorkerFallbackForeignDescendants + Test-PreExistingCleanupOwnership + Test-SmokePromotionInterruptionAuthority + Test-PreExistingAppPathsAuthority + Test-HkcuInstalledValueOwnership + Test-ProvisionalUserMarkerOwnership + Write-Host "PROPR_WINDOWS_SUPERVISOR_TESTS:${Architecture}:PASSED" + [Console]::Out.Flush() +} finally { + if (Test-Path -LiteralPath $testRoot) { + Remove-Item -LiteralPath $testRoot -Recurse -Force -ErrorAction SilentlyContinue + } +} diff --git a/apps/desktop/scripts/test-installed-windows-app.ps1 b/apps/desktop/scripts/test-installed-windows-app.ps1 index dc9b440e7..557dda2d4 100644 --- a/apps/desktop/scripts/test-installed-windows-app.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app.ps1 @@ -1,6 +1,9 @@ param( [Parameter(Mandatory=$true)][string]$Installer, - [Parameter(Mandatory=$true)][ValidateSet('x64','arm64')][string]$Architecture + [Parameter(Mandatory=$true)][ValidateSet('x64','arm64')][string]$Architecture, + [Parameter(Mandatory=$true)][string]$WatchdogMarker, + [Parameter(Mandatory=$true)][string]$OwnershipReadyEvent, + [Parameter(Mandatory=$true)][string]$OwnershipManifest ) enum SmokeEvidenceInspectionPhase { @@ -14,6 +17,61 @@ enum SmokeEvidenceInspectionPhase { } $ErrorActionPreference = 'Stop' +$bootstrapWatchdogTimeoutMilliseconds = 60 * 1000 +$markerTransitionTimeoutMilliseconds = 30 * 1000 +$ownershipHandshakeTimeoutMilliseconds = 5 * 1000 +if ($OwnershipReadyEvent -notmatch '^Local\\ProPRInstalledApp-[a-f0-9]{32}$') { + throw 'worker ownership event name is invalid' +} +$ownershipReady = [Threading.EventWaitHandle]::OpenExisting($OwnershipReadyEvent) +try { + if (!$ownershipReady.WaitOne($ownershipHandshakeTimeoutMilliseconds)) { + throw 'worker ownership was not established' + } +} finally { + $ownershipReady.Dispose() +} +$watchdogMarkerPath = [IO.Path]::GetFullPath($WatchdogMarker) +$watchdogMarkerParent = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') +if ((Split-Path -Leaf $watchdogMarkerPath) -notmatch + '^propr-installed-app-watchdog-[a-f0-9]{32}\.marker$' -or + ![string]::Equals( + (Split-Path -Parent $watchdogMarkerPath).TrimEnd('\'), + $watchdogMarkerParent, + [StringComparison]::OrdinalIgnoreCase + )) { + throw 'watchdog marker path is invalid' +} +$ownershipManifestPath = [IO.Path]::GetFullPath($OwnershipManifest) +if ((Split-Path -Leaf $ownershipManifestPath) -notmatch + '^propr-installed-app-ownership-[a-f0-9]{32}\.json$' -or + ![string]::Equals( + (Split-Path -Parent $ownershipManifestPath).TrimEnd('\'), + $watchdogMarkerParent, + [StringComparison]::OrdinalIgnoreCase + )) { + throw 'ownership manifest path is invalid' +} +$bootstrapDeadline = [DateTime]::UtcNow.AddMilliseconds($bootstrapWatchdogTimeoutMilliseconds).Ticks +$bootstrapRecord = '{0}|INITIALIZATION|PATHS|BEGIN' -f $bootstrapDeadline +$bootstrapBytes = [Text.Encoding]::ASCII.GetBytes($bootstrapRecord) +$bootstrapStream = [IO.FileStream]::new( + $watchdogMarkerPath, + [IO.FileMode]::CreateNew, + [IO.FileAccess]::Write, + [IO.FileShare]::Read, + 4096, + [IO.FileOptions]::WriteThrough +) +try { + $bootstrapStream.Write($bootstrapBytes, 0, $bootstrapBytes.Length) + $bootstrapStream.Flush($true) +} finally { + $bootstrapStream.Dispose() +} +Write-Host 'PROPR_WINDOWS_INSTALLED_SMOKE:OPERATION:INITIALIZATION:PATHS:BEGIN' +[Console]::Out.Flush() + $primaryFailure = $null try { $installerPath = (Resolve-Path -LiteralPath $Installer -ErrorAction Stop).Path @@ -22,17 +80,52 @@ try { } $installRoot = Join-Path $env:ProgramFiles 'ProPR Desktop' $application = Join-Path $installRoot 'propr-desktop.exe' +$protocolRegistryPath = 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr' +$appPathsRegistryPath = ` + 'Registry::HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\CurrentVersion\App Paths\propr-desktop.exe' +$hkcuDesktopRegistryPath = 'Registry::HKEY_CURRENT_USER\Software\ProPR\Desktop' +$hkcuInstalledValueName = 'installed' $testUser = "propr-ci-$([Guid]::NewGuid().ToString('N').Substring(0,8))" $passwordText = "P!$([Guid]::NewGuid().ToString('N'))a7" $password = ConvertTo-SecureString $passwordText -AsPlainText -Force +$passwordText = $null $credential = New-Object Management.Automation.PSCredential("$env:COMPUTERNAME\$testUser", $password) $installAttempted = $false +$msiInstallCompleted = $false +$installerArtifactAuthorityValid = $true +$testUserCreatedByRun = $false $testUserSid = $null $smokeUserDataDirectory = $null +$smokeOwnershipRecord = $null +$installRootExistedBeforeInstall = $false +$protocolExistedBeforeInstall = $false +$appPathsExistedBeforeInstall = $false +$hkcuDesktopKeyExistedBeforeInstall = $false +$hkcuInstalledValueExistedBeforeInstall = $false +$hkcuInstalledBaselineKind = $null +$hkcuInstalledBaselineData = $null +$installRootCreatedByRun = $false +$protocolCreatedByRun = $false +$appPathsCreatedByRun = $false +$protocolOwnedIdentity = $null +$appPathsOwnedIdentity = $null +$installRootOwnedIdentity = $null +$installRootOwnedTreeIdentity = $null +$shortcutFolderOwnedIdentity = $null +$shortcutFolderOwnedTreeIdentity = $null +$hkcuInstalledOwnedKind = $null +$hkcuInstalledOwnedData = $null +$shortcutOwnedIdentity = $null +$shortcutOwnedEntryIdentity = $null +$hkcuDesktopKeyCreatedByRun = $false $msiTimeoutMilliseconds = 10 * 60 * 1000 +$msiCaptureRollbackGraceMilliseconds = 30 * 1000 $applicationTimeoutMilliseconds = 5 * 60 * 1000 $terminationTimeoutMilliseconds = 30 * 1000 $redirectedStreamDrainTimeoutMilliseconds = 30 * 1000 +$externalOperationTimeoutMilliseconds = 60 * 1000 +$recursiveOperationTimeoutMilliseconds = 90 * 1000 +$alternateUserLaunchTimeoutMilliseconds = 90 * 1000 $smokeEvidenceFileByteCap = 64 * 1024 $smokeEvidenceOpenRetryDeadlineMilliseconds = 2 * 1000 $smokeEvidenceOpenRetryDelayMilliseconds = 50 @@ -84,11 +177,715 @@ if (!$commonPrograms -or ![IO.Path]::IsPathRooted($commonPrograms)) { $commonPrograms = (Resolve-Path -LiteralPath $commonPrograms -ErrorAction Stop).Path $startMenuShortcutFolder = Join-Path $commonPrograms 'ProPR Desktop' $startMenuShortcut = Join-Path $startMenuShortcutFolder 'ProPR Desktop.lnk' +$installRootExistedBeforeInstall = Test-Path -LiteralPath $installRoot +$protocolExistedBeforeInstall = + Test-Path -LiteralPath $protocolRegistryPath +$appPathsExistedBeforeInstall = Test-Path -LiteralPath $appPathsRegistryPath +$hkcuDesktopKeyExistedBeforeInstall = Test-Path -LiteralPath $hkcuDesktopRegistryPath $startMenuShortcutExistedBeforeInstall = Test-Path -LiteralPath $startMenuShortcut $startMenuShortcutFolderExistedBeforeInstall = Test-Path -LiteralPath $startMenuShortcutFolder $startMenuShortcutCreatedByRun = $false $startMenuShortcutFolderCreatedByRun = $false $shortcutFileByteCap = 64 * 1024 +$ownershipRunId = [IO.Path]::GetFileNameWithoutExtension($ownershipManifestPath).Substring( + 'propr-installed-app-ownership-'.Length) +$initialManifestItem = Get-Item -LiteralPath $ownershipManifestPath -Force -ErrorAction Stop +if (($initialManifestItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + $initialManifestItem.Length -le 0 -or $initialManifestItem.Length -gt 65536) { + throw 'initial ownership manifest metadata is invalid' +} +$initialManifestBytes = [byte[]]::new([int]$initialManifestItem.Length) +$initialManifestStream = [IO.File]::Open( + $ownershipManifestPath, + [IO.FileMode]::Open, + [IO.FileAccess]::Read, + [IO.FileShare]::Read +) +try { + $initialManifestOffset = 0 + while ($initialManifestOffset -lt $initialManifestBytes.Length) { + $read = $initialManifestStream.Read( + $initialManifestBytes, + $initialManifestOffset, + $initialManifestBytes.Length - $initialManifestOffset + ) + if ($read -eq 0) { throw 'initial ownership manifest read was incomplete' } + $initialManifestOffset += $read + } + if ($initialManifestStream.ReadByte() -ne -1) { + throw 'initial ownership manifest changed during read' + } +} finally { + $initialManifestStream.Dispose() +} +$strictUtf8 = [Text.UTF8Encoding]::new($false, $true) +$initialOwnershipState = ConvertFrom-Json ` + -InputObject $strictUtf8.GetString($initialManifestBytes) -ErrorAction Stop +$initialManifestKeys = @($initialOwnershipState.PSObject.Properties | ForEach-Object { $_.Name }) +$expectedInitialManifestKeys = @( + 'SchemaVersion','ManifestType','State','RunId','CreatedUtcTicks','ExpiresUtcTicks', + 'InstallerPath','InstallerEntryIdentity','InstallerSha256','InstallerProductCode', + 'Fixture','FixtureRoot','BaselineClean','InstallAttempted','MsiTransactionState', + 'Directories','Files','RegistryKeys','RegistryValues','Users','Profiles' +) +if ($initialManifestKeys.Count -ne $expectedInitialManifestKeys.Count -or + @($expectedInitialManifestKeys | Where-Object { + $initialManifestKeys -cnotcontains $_ + }).Count -ne 0 -or + $initialOwnershipState.SchemaVersion -ne 3 -or + [string]$initialOwnershipState.ManifestType -cne + 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP' -or + [string]$initialOwnershipState.State -cne 'ACTIVE' -or + [string]$initialOwnershipState.RunId -cne $ownershipRunId -or + ![string]::Equals( + [IO.Path]::GetFullPath([string]$initialOwnershipState.InstallerPath), + $installerPath, + [StringComparison]::OrdinalIgnoreCase + ) -or + [string]$initialOwnershipState.InstallerEntryIdentity -notmatch '^[a-f0-9]{24}$' -or + [string]$initialOwnershipState.InstallerSha256 -notmatch '^[a-f0-9]{64}$' -or + [string]$initialOwnershipState.InstallerProductCode -notmatch + '^\{[A-F0-9]{8}(?:-[A-F0-9]{4}){3}-[A-F0-9]{12}\}$' -or + $initialOwnershipState.Fixture -isnot [bool] -or $initialOwnershipState.Fixture -or + $null -ne $initialOwnershipState.FixtureRoot -or + $initialOwnershipState.BaselineClean -isnot [bool] -or + $initialOwnershipState.BaselineClean -or + $initialOwnershipState.InstallAttempted -isnot [bool] -or + $initialOwnershipState.InstallAttempted -or + [string]$initialOwnershipState.MsiTransactionState -cne 'NONE' -or + @($initialOwnershipState.Directories).Count -ne 0 -or + @($initialOwnershipState.Files).Count -ne 0 -or + @($initialOwnershipState.RegistryKeys).Count -ne 0 -or + @($initialOwnershipState.RegistryValues).Count -ne 0 -or + @($initialOwnershipState.Users).Count -ne 0 -or + @($initialOwnershipState.Profiles).Count -ne 0) { + throw 'initial ownership manifest identity is invalid' +} +$ownershipToken = [Guid]::NewGuid().ToString('N') +$ownershipState = [ordered]@{ + SchemaVersion = 3 + ManifestType = 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP' + State = 'ACTIVE' + RunId = $ownershipRunId + CreatedUtcTicks = [int64]$initialOwnershipState.CreatedUtcTicks + ExpiresUtcTicks = [int64]$initialOwnershipState.ExpiresUtcTicks + InstallerPath = $installerPath + InstallerEntryIdentity = [string]$initialOwnershipState.InstallerEntryIdentity + InstallerSha256 = [string]$initialOwnershipState.InstallerSha256 + InstallerProductCode = [string]$initialOwnershipState.InstallerProductCode + Fixture = $false + FixtureRoot = $null + BaselineClean = $false + InstallAttempted = $false + MsiTransactionState = 'NONE' + Directories = @() + Files = @() + RegistryKeys = @() + RegistryValues = @() + Users = @() + Profiles = @() +} + +function Write-OwnershipManifest { + $temporaryManifest = "$ownershipManifestPath.new" + $bytes = [Text.Encoding]::UTF8.GetBytes( + ($ownershipState | ConvertTo-Json -Depth 6 -Compress)) + $stream = [IO.FileStream]::new( + $temporaryManifest, + [IO.FileMode]::Create, + [IO.FileAccess]::Write, + [IO.FileShare]::None, + 4096, + [IO.FileOptions]::WriteThrough + ) + try { + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + } finally { + $stream.Dispose() + } + [IO.File]::Move($temporaryManifest, $ownershipManifestPath, $true) +} + +function Test-SamePath([string]$Left, [string]$Right) { + return [string]::Equals( + [IO.Path]::GetFullPath($Left).TrimEnd('\'), + [IO.Path]::GetFullPath($Right).TrimEnd('\'), + [StringComparison]::OrdinalIgnoreCase + ) +} + +function Resolve-CanonicalNonReparseDirectory([string]$Path, [string]$Label) { + if ([string]::IsNullOrWhiteSpace($Path) -or ![IO.Path]::IsPathRooted($Path)) { + throw "$Label path is invalid" + } + $fullPath = [IO.Path]::GetFullPath($Path).TrimEnd('\') + $pathRoot = [IO.Path]::GetPathRoot($fullPath) + if ([string]::IsNullOrWhiteSpace($pathRoot)) { throw "$Label path root is invalid" } + $rootItem = Get-Item -LiteralPath $pathRoot -Force -ErrorAction Stop + if (!$rootItem.PSIsContainer -or + ($rootItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "$Label path root is invalid" + } + $currentPath = $pathRoot + $components = @($fullPath.Substring($pathRoot.Length) -split '\\' | + Where-Object { $_.Length -ne 0 }) + foreach ($component in $components) { + $currentPath = Join-Path $currentPath $component + $item = Get-Item -LiteralPath $currentPath -Force -ErrorAction Stop + if (!$item.PSIsContainer -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "$Label path has invalid ancestry" + } + } + $resolved = (Resolve-Path -LiteralPath $fullPath -ErrorAction Stop).ProviderPath.TrimEnd('\') + if (![string]::Equals( + [IO.Path]::GetFullPath($resolved).TrimEnd('\'), + $fullPath, + [StringComparison]::OrdinalIgnoreCase + )) { + throw "$Label path is not canonical" + } + return $fullPath +} + +function Resolve-SystemProfilesDirectory { + $profileListPath = 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList' + $configured = [string](Get-ItemPropertyValue -LiteralPath $profileListPath ` + -Name 'ProfilesDirectory' -ErrorAction Stop) + $expanded = [Environment]::ExpandEnvironmentVariables($configured) + return Resolve-CanonicalNonReparseDirectory $expanded 'system profiles directory' +} + +function Resolve-ValidatedOwnedProfilePath([string]$LocalPath, [string]$UserName) { + if ($UserName -notmatch '^(?:propr-ci-|prpr)[a-f0-9]{8}$') { + throw 'owned profile username is invalid' + } + $profilesDirectory = Resolve-SystemProfilesDirectory + $canonicalLocalPath = Resolve-CanonicalNonReparseDirectory $LocalPath 'profile local' + if (!(Test-SamePath (Split-Path -Parent $canonicalLocalPath) $profilesDirectory) -or + (Split-Path -Leaf $canonicalLocalPath) -cne $UserName) { + throw 'profile local path is not the exact owned direct child of ProfilesDirectory' + } + return $canonicalLocalPath +} + +function Write-DurableOwnershipToken([string]$Path, [string]$Token) { + $bytes = [Text.Encoding]::ASCII.GetBytes($Token) + $stream = [IO.FileStream]::new( + $Path, + [IO.FileMode]::CreateNew, + [IO.FileAccess]::Write, + [IO.FileShare]::Read, + 4096, + [IO.FileOptions]::WriteThrough + ) + try { + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + } finally { + $stream.Dispose() + } +} + +Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +public static class ProPRDirectoryIdentity +{ + [StructLayout(LayoutKind.Sequential)] + private struct BY_HANDLE_FILE_INFORMATION + { + public uint FileAttributes; + public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime; + public uint VolumeSerialNumber; + public uint FileSizeHigh; + public uint FileSizeLow; + public uint NumberOfLinks; + public uint FileIndexHigh; + public uint FileIndexLow; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFile( + string path, uint access, uint share, IntPtr security, uint creation, + uint flags, IntPtr template); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetFileInformationByHandle( + SafeFileHandle handle, out BY_HANDLE_FILE_INFORMATION information); + + public static string ReadEntry(string path, bool expectDirectory) + { + using (SafeFileHandle handle = CreateFile( + path, 0x80, 0x7, IntPtr.Zero, 3, 0x02200000, IntPtr.Zero)) + { + if (handle == null || handle.IsInvalid) + throw new Win32Exception(Marshal.GetLastWin32Error(), "directory identity open failed"); + BY_HANDLE_FILE_INFORMATION information; + if (!GetFileInformationByHandle(handle, out information)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "directory identity read failed"); + bool isDirectory = (information.FileAttributes & 0x10) != 0; + if ((information.FileAttributes & 0x400) != 0 || isDirectory != expectDirectory) + throw new InvalidOperationException("file-system object identity changed"); + return string.Format("{0:x8}{1:x8}{2:x8}", information.VolumeSerialNumber, + information.FileIndexHigh, information.FileIndexLow); + } + } + + public static string Read(string path) { return ReadEntry(path, true); } +} +'@ + +function Get-FileIdentity([string]$Path) { + if (!(Test-Path -LiteralPath $Path -PathType Leaf)) { return $null } + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + $item.Length -gt $shortcutFileByteCap) { + return $null + } + $stream = [IO.File]::Open( + $Path, + [IO.FileMode]::Open, + [IO.FileAccess]::Read, + [IO.FileShare]::Read + ) + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + return [BitConverter]::ToString($sha256.ComputeHash($stream)).Replace('-', '').ToLowerInvariant() + } finally { + $sha256.Dispose() + $stream.Dispose() + } +} + +function Get-DirectoryIdentity([string]$Path) { + if (!(Test-Path -LiteralPath $Path -PathType Container)) { return $null } + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { return $null } + return [ProPRDirectoryIdentity]::Read($item.FullName) +} + +function Get-FileSystemEntryIdentity([string]$Path, [bool]$Directory) { + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if ($item.PSIsContainer -ne $Directory -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'file-system object identity is invalid' + } + return [ProPRDirectoryIdentity]::ReadEntry($item.FullName, $Directory) +} + +function Get-FileSystemTreeIdentity([string]$Path) { + $root = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if (!$root.PSIsContainer -or + ($root.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'file-system tree root identity is invalid' + } + $rootPath = $root.FullName.TrimEnd('\') + $records = [Collections.Generic.List[string]]::new() + $records.Add(('D||{0}' -f (Get-FileSystemEntryIdentity $rootPath $true))) + foreach ($entry in @(Get-ChildItem -LiteralPath $rootPath -Recurse -Force -ErrorAction Stop)) { + if (($entry.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'file-system tree contains a reparse point' + } + $relativePath = $entry.FullName.Substring($rootPath.Length).TrimStart('\') + if (!$relativePath -or [IO.Path]::IsPathRooted($relativePath)) { + throw 'file-system tree relative path is invalid' + } + $kind = if ($entry.PSIsContainer) { 'D' } else { 'F' } + $relative = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($relativePath)) + $identity = Get-FileSystemEntryIdentity $entry.FullName ([bool]$entry.PSIsContainer) + $records.Add(('{0}|{1}|{2}' -f $kind, $relative, $identity)) + } + $recordArray = $records.ToArray() + [Array]::Sort($recordArray, [StringComparer]::Ordinal) + $payload = [Text.Encoding]::UTF8.GetBytes(($recordArray -join "`n")) + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + return [BitConverter]::ToString($sha256.ComputeHash($payload)).Replace('-', '').ToLowerInvariant() + } finally { + $sha256.Dispose() + } +} + +function Assert-MsiManagedFileSystemAuthority { + if (Test-Path -LiteralPath $installRoot) { + if (!$installRootCreatedByRun -or + [string]$installRootOwnedIdentity -notmatch '^[a-f0-9]{24}$' -or + [string]$installRootOwnedTreeIdentity -notmatch '^[a-f0-9]{64}$' -or + (Get-DirectoryIdentity $installRoot) -cne $installRootOwnedIdentity -or + (Get-FileSystemTreeIdentity $installRoot) -cne $installRootOwnedTreeIdentity) { + throw 'refusing to uninstall over an install tree with mismatched ownership identity' + } + } + if (Test-Path -LiteralPath $startMenuShortcutFolder) { + if (!$startMenuShortcutFolderCreatedByRun -or + [string]$shortcutFolderOwnedIdentity -notmatch '^[a-f0-9]{24}$' -or + [string]$shortcutFolderOwnedTreeIdentity -notmatch '^[a-f0-9]{64}$' -or + (Get-DirectoryIdentity $startMenuShortcutFolder) -cne $shortcutFolderOwnedIdentity -or + (Get-FileSystemTreeIdentity $startMenuShortcutFolder) -cne + $shortcutFolderOwnedTreeIdentity) { + throw 'refusing to uninstall over a shortcut folder with mismatched ownership identity' + } + } + if (Test-Path -LiteralPath $startMenuShortcut) { + if (!$startMenuShortcutCreatedByRun -or + [string]$shortcutOwnedIdentity -notmatch '^[a-f0-9]{64}$' -or + [string]$shortcutOwnedEntryIdentity -notmatch '^[a-f0-9]{24}$' -or + (Get-FileIdentity $startMenuShortcut) -cne $shortcutOwnedIdentity -or + (Get-FileSystemEntryIdentity $startMenuShortcut $false) -cne + $shortcutOwnedEntryIdentity) { + throw 'refusing to uninstall over a shortcut with mismatched ownership identity' + } + } +} + +function Get-RegistryTreeIdentity([string]$Path) { + if (!(Test-Path -LiteralPath $Path)) { return $null } + $root = Get-Item -LiteralPath $Path -ErrorAction Stop + $records = [Collections.Generic.List[string]]::new() + $pending = [Collections.Generic.Queue[object]]::new() + $pending.Enqueue([PSCustomObject]@{ Key = $root; Relative = '' }) + while ($pending.Count -ne 0) { + $entry = $pending.Dequeue() + $records.Add(('K|{0}' -f [Convert]::ToBase64String( + [Text.Encoding]::UTF8.GetBytes([string]$entry.Relative)))) + foreach ($valueName in @($entry.Key.GetValueNames() | Sort-Object -CaseSensitive)) { + $value = $entry.Key.GetValue( + $valueName, + $null, + [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames + ) + $valueBytes = if ($value -is [byte[]]) { + $value + } elseif ($value -is [string[]]) { + [Text.Encoding]::UTF8.GetBytes(($value | ConvertTo-Json -Compress)) + } else { + [Text.Encoding]::UTF8.GetBytes([Convert]::ToString( + $value, + [Globalization.CultureInfo]::InvariantCulture + )) + } + $records.Add(('V|{0}|{1}|{2}' -f + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([string]$valueName)), + $entry.Key.GetValueKind($valueName).ToString(), + [Convert]::ToBase64String($valueBytes))) + } + foreach ($child in @(Get-ChildItem -LiteralPath $entry.Key.PSPath -ErrorAction Stop | + Sort-Object -Property PSChildName -CaseSensitive)) { + $relative = if ($entry.Relative) { + '{0}\{1}' -f $entry.Relative, $child.PSChildName + } else { [string]$child.PSChildName } + $pending.Enqueue([PSCustomObject]@{ Key = $child; Relative = $relative }) + } + } + $payload = [Text.Encoding]::UTF8.GetBytes(($records -join "`n")) + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + return [BitConverter]::ToString($sha256.ComputeHash($payload)).Replace('-', '').ToLowerInvariant() + } + finally { $sha256.Dispose() } +} + +function Convert-RegistryValueToBytes( + [Microsoft.Win32.RegistryValueKind]$Kind, + $Value +) { + switch ($Kind) { + 'DWord' { return [BitConverter]::GetBytes([int32]$Value) } + 'QWord' { return [BitConverter]::GetBytes([int64]$Value) } + 'String' { return [Text.Encoding]::UTF8.GetBytes([string]$Value) } + 'ExpandString' { return [Text.Encoding]::UTF8.GetBytes([string]$Value) } + 'MultiString' { + return [Text.Encoding]::UTF8.GetBytes( + (ConvertTo-Json -InputObject @([string[]]$Value) -Compress)) + } + 'Binary' { return [byte[]]$Value } + 'None' { return [byte[]]$Value } + default { throw 'registry value kind is unsupported' } + } +} + +function Get-RegistryValueSnapshot([string]$Path, [string]$Name) { + if (!(Test-Path -LiteralPath $Path)) { + return [PSCustomObject]@{ Exists = $false; Kind = $null; Data = $null } + } + $key = Get-Item -LiteralPath $Path -ErrorAction Stop + if (@($key.GetValueNames()) -cnotcontains $Name) { + return [PSCustomObject]@{ Exists = $false; Kind = $null; Data = $null } + } + $kind = $key.GetValueKind($Name) + $value = $key.GetValue( + $Name, + $null, + [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames + ) + return [PSCustomObject]@{ + Exists = $true + Kind = $kind.ToString() + Data = [Convert]::ToBase64String((Convert-RegistryValueToBytes $kind $value)) + } +} + +function Get-InstallerSha256([string]$Path) { + $stream = [IO.File]::Open( + $Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + return [BitConverter]::ToString($sha256.ComputeHash($stream)).Replace('-', '').ToLowerInvariant() + } finally { + $sha256.Dispose() + $stream.Dispose() + } +} + +function Assert-InstallerArtifactAuthority { + $matches = $false + try { + $matches = (Test-SamePath $installerPath ([string]$ownershipState.InstallerPath)) -and + [string]$ownershipState.InstallerEntryIdentity -match '^[a-f0-9]{24}$' -and + [string]$ownershipState.InstallerSha256 -match '^[a-f0-9]{64}$' -and + [string]$ownershipState.InstallerProductCode -match + '^\{[A-F0-9]{8}(?:-[A-F0-9]{4}){3}-[A-F0-9]{12}\}$' -and + (Get-FileSystemEntryIdentity $installerPath $false) -ceq + [string]$ownershipState.InstallerEntryIdentity -and + (Get-InstallerSha256 $installerPath) -ceq [string]$ownershipState.InstallerSha256 + } catch {} + if (!$matches) { + $script:installerArtifactAuthorityValid = $false + throw 'installer artifact no longer matches durable authority' + } +} + +function Assert-MsiProductIsUnregistered([string]$ProductCode) { + $installerCom = $null + try { + if ($ProductCode -notmatch '^\{[A-F0-9]{8}(?:-[A-F0-9]{4}){3}-[A-F0-9]{12}\}$') { + throw 'MSI product identity is invalid' + } + $installerCom = New-Object -ComObject WindowsInstaller.Installer + if ([int]$installerCom.ProductState($ProductCode) -ne -1) { + throw 'Windows Installer product registration is not at the clean baseline' + } + } finally { + if ($null -ne $installerCom -and + [Runtime.InteropServices.Marshal]::IsComObject($installerCom)) { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($installerCom) + } + } +} + +function Assert-ExactCleanMsiBaselineAfterRollback { + foreach ($path in @( + $installRoot, + $startMenuShortcutFolder, + $protocolRegistryPath, + $appPathsRegistryPath + )) { + if (Test-Path -LiteralPath $path) { + throw 'Windows Installer rollback did not restore the exact clean baseline' + } + } + $current = Get-RegistryValueSnapshot $hkcuDesktopRegistryPath $hkcuInstalledValueName + $valueMatches = if ($hkcuInstalledValueExistedBeforeInstall) { + $current.Exists -and $current.Kind -ceq $hkcuInstalledBaselineKind -and + $current.Data -ceq $hkcuInstalledBaselineData + } else { !$current.Exists } + $keyMatches = (Test-Path -LiteralPath $hkcuDesktopRegistryPath) -eq + $hkcuDesktopKeyExistedBeforeInstall + if (!$valueMatches -or !$keyMatches) { + throw 'Windows Installer rollback did not restore the exact current-user baseline' + } + Assert-InstallerArtifactAuthority + Assert-MsiProductIsUnregistered ([string]$ownershipState.InstallerProductCode) +} + +function Wait-ExactCleanMsiBaselineAfterRollback { + $stopwatch = [Diagnostics.Stopwatch]::StartNew() + do { + try { + Assert-ExactCleanMsiBaselineAfterRollback + return + } catch { + if ($stopwatch.ElapsedMilliseconds -ge $msiCaptureRollbackGraceMilliseconds) { + throw 'Windows Installer rollback clean-baseline grace expired' + } + } + Start-Sleep -Milliseconds 100 + } while ($true) +} + +function Test-MsiInstalledValue([string]$Path, [string]$Name) { + $snapshot = Get-RegistryValueSnapshot $Path $Name + return $snapshot.Exists -and $snapshot.Kind -ceq 'DWord' -and + $snapshot.Data -ceq [Convert]::ToBase64String([BitConverter]::GetBytes([int32]1)) +} + +function Restore-HkcuInstalledBaseline { + $current = Get-RegistryValueSnapshot $hkcuDesktopRegistryPath $hkcuInstalledValueName + $matchesBaseline = $hkcuInstalledValueExistedBeforeInstall -and $current.Exists -and + $current.Kind -ceq $hkcuInstalledBaselineKind -and + $current.Data -ceq $hkcuInstalledBaselineData + $matchesOwnedIdentity = $current.Exists -and $hkcuInstalledOwnedKind -and + $hkcuInstalledOwnedData -and $current.Kind -ceq $hkcuInstalledOwnedKind -and + $current.Data -ceq $hkcuInstalledOwnedData + if ($current.Exists -and !$matchesBaseline -and !$matchesOwnedIdentity) { + throw 'refusing to replace a conflicting current-user installed value' + } + + if ($hkcuInstalledValueExistedBeforeInstall) { + if (!(Test-Path -LiteralPath $hkcuDesktopRegistryPath)) { + [void](New-Item -Path $hkcuDesktopRegistryPath -Force -ErrorAction Stop) + } + if (!$matchesBaseline) { + $kind = [Enum]::Parse( + [Microsoft.Win32.RegistryValueKind], $hkcuInstalledBaselineKind, $false) + $bytes = [Convert]::FromBase64String($hkcuInstalledBaselineData) + $value = switch ($kind) { + 'DWord' { [BitConverter]::ToInt32($bytes, 0); break } + 'QWord' { [BitConverter]::ToInt64($bytes, 0); break } + 'String' { [Text.Encoding]::UTF8.GetString($bytes); break } + 'ExpandString' { [Text.Encoding]::UTF8.GetString($bytes); break } + 'MultiString' { + @([string[]](ConvertFrom-Json -InputObject ([Text.Encoding]::UTF8.GetString($bytes)))) + break + } + 'Binary' { $bytes; break } + 'None' { $bytes; break } + default { throw 'registry baseline kind is unsupported' } + } + (Get-Item -LiteralPath $hkcuDesktopRegistryPath -ErrorAction Stop).SetValue( + $hkcuInstalledValueName, $value, $kind) + } + } elseif ($current.Exists) { + Remove-ItemProperty -LiteralPath $hkcuDesktopRegistryPath ` + -Name $hkcuInstalledValueName -Force -ErrorAction Stop + } + + if ($hkcuDesktopKeyCreatedByRun -and (Test-Path -LiteralPath $hkcuDesktopRegistryPath)) { + $key = Get-Item -LiteralPath $hkcuDesktopRegistryPath -ErrorAction Stop + if (@($key.GetValueNames()).Count -eq 0 -and @($key.GetSubKeyNames()).Count -eq 0) { + Remove-Item -LiteralPath $hkcuDesktopRegistryPath -Force -ErrorAction Stop + } + } +} + +$hkcuInstalledSnapshot = Get-RegistryValueSnapshot ` + $hkcuDesktopRegistryPath $hkcuInstalledValueName +$hkcuInstalledValueExistedBeforeInstall = [bool]$hkcuInstalledSnapshot.Exists +$hkcuInstalledBaselineKind = $hkcuInstalledSnapshot.Kind +$hkcuInstalledBaselineData = $hkcuInstalledSnapshot.Data +$ownershipState.RegistryValues = @([ordered]@{ + Kind = 'HKCU_INSTALLED' + Path = $hkcuDesktopRegistryPath + Name = $hkcuInstalledValueName + Owned = $false + Provisional = $false + BaselineKeyExisted = $hkcuDesktopKeyExistedBeforeInstall + BaselineValueExisted = $hkcuInstalledValueExistedBeforeInstall + BaselineValueKind = $hkcuInstalledBaselineKind + BaselineValueData = $hkcuInstalledBaselineData + IdentityValueKind = $null + IdentityValueData = $null + KeyCreatedByRun = $false +}) + +Write-OwnershipManifest + +function Write-WatchdogMarker( + [ValidateSet('INITIALIZATION','INSTALL','VALIDATION','USER_SETUP','APP_LAUNCH','APP_EXIT','UNINSTALL','CLEANUP')] + [string]$Stage, + [ValidateSet( + 'PATHS', + 'BASELINE', + 'MSI_INSTALL', + 'OWNERSHIP_CAPTURE', + 'INSTALL_TREE_SCAN', + 'APPLICATION_IMAGE', + 'PROTOCOL_ASSERTION', + 'APP_PATH_ASSERTION', + 'HKCU_INSTALLED_ASSERTION', + 'SHORTCUT_ASSERTION', + 'USER_CREATE', + 'USER_SID', + 'SMOKE_DATA_CREATE', + 'SHORTCUT_PRESENT_PROBE', + 'ALTERNATE_USER_START', + 'APPLICATION_WAIT', + 'STREAM_DRAIN', + 'EVIDENCE_INSPECTION', + 'MSI_UNINSTALL', + 'INSTALL_TREE_ASSERTION', + 'PROTOCOL_ABSENCE_ASSERTION', + 'APP_PATH_ABSENCE_ASSERTION', + 'HKCU_INSTALLED_ABSENCE_ASSERTION', + 'SHORTCUT_FILE_ASSERTION', + 'SHORTCUT_FOLDER_ASSERTION', + 'SHORTCUT_ABSENCE_PROBE', + 'SMOKE_DATA_REMOVE', + 'PROFILE_LOOKUP', + 'PROFILE_REMOVE', + 'USER_LOOKUP', + 'USER_REMOVE', + 'INSTALL_ROOT_FALLBACK', + 'PROTOCOL_FALLBACK', + 'APP_PATH_FALLBACK', + 'HKCU_INSTALLED_FALLBACK', + 'SHORTCUT_FALLBACK' + )][string]$Substage, + [int]$TimeoutMilliseconds, + [ValidateSet('BEGIN','COMPLETE','FAILED')][string]$Status +) { + $deadline = if ($Status -eq 'BEGIN') { + [DateTime]::UtcNow.AddMilliseconds($TimeoutMilliseconds).Ticks + } else { + [DateTime]::UtcNow.AddMilliseconds($markerTransitionTimeoutMilliseconds).Ticks + } + $record = '{0}|{1}|{2}|{3}' -f $deadline, $Stage, $Substage, $Status + $temporaryMarker = "$watchdogMarkerPath.$PID.new" + $bytes = [Text.Encoding]::ASCII.GetBytes($record) + $stream = $null + try { + $stream = [IO.FileStream]::new( + $temporaryMarker, + [IO.FileMode]::Create, + [IO.FileAccess]::Write, + [IO.FileShare]::None, + 4096, + [IO.FileOptions]::WriteThrough + ) + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + } finally { + if ($null -ne $stream) { $stream.Dispose() } + } + [IO.File]::Move($temporaryMarker, $watchdogMarkerPath, $true) + Write-Host ('PROPR_WINDOWS_INSTALLED_SMOKE:OPERATION:{0}:{1}:{2}' -f ` + $Stage, $Substage, $Status) + [Console]::Out.Flush() +} + +function Invoke-BoundedExternalOperation( + [string]$Stage, + [string]$Substage, + [int]$TimeoutMilliseconds, + [scriptblock]$Operation +) { + Write-WatchdogMarker $Stage $Substage $TimeoutMilliseconds 'BEGIN' + try { + $result = & $Operation + Write-WatchdogMarker $Stage $Substage $TimeoutMilliseconds 'COMPLETE' + return $result + } catch { + Write-WatchdogMarker $Stage $Substage $TimeoutMilliseconds 'FAILED' + throw + } +} Add-Type -TypeDefinition @' using System; @@ -113,11 +910,30 @@ public static class ProPRWindowsLogon } '@ +Write-WatchdogMarker 'INITIALIZATION' 'PATHS' $bootstrapWatchdogTimeoutMilliseconds 'COMPLETE' +Write-WatchdogMarker 'INITIALIZATION' 'BASELINE' $externalOperationTimeoutMilliseconds 'BEGIN' +try { + if ($installRootExistedBeforeInstall -or $protocolExistedBeforeInstall -or + $appPathsExistedBeforeInstall -or + $startMenuShortcutExistedBeforeInstall -or $startMenuShortcutFolderExistedBeforeInstall) { + throw 'installed-app harness requires an unowned clean machine baseline' + } + Assert-InstallerArtifactAuthority + Assert-MsiProductIsUnregistered ([string]$ownershipState.InstallerProductCode) + $ownershipState.BaselineClean = $true + Write-OwnershipManifest + Write-WatchdogMarker 'INITIALIZATION' 'BASELINE' $externalOperationTimeoutMilliseconds 'COMPLETE' +} catch { + Write-WatchdogMarker 'INITIALIZATION' 'BASELINE' $externalOperationTimeoutMilliseconds 'FAILED' + throw +} + function Write-Stage( [ValidateSet('INSTALL','VALIDATION','USER_SETUP','APP_LAUNCH','APP_EXIT','UNINSTALL','CLEANUP')][string]$Stage, [ValidateSet('BEGIN','COMPLETE','FAILED')][string]$Status ) { Write-Host ('PROPR_WINDOWS_INSTALLED_SMOKE:{0}:{1}' -f $Stage, $Status) + [Console]::Out.Flush() } function Write-CleanupSubstage( @@ -126,6 +942,8 @@ function Write-CleanupSubstage( 'MSI_UNINSTALL', 'INSTALL_TREE', 'PROTOCOL', + 'APP_PATH', + 'HKCU_INSTALLED', 'SHORTCUT_FILE', 'SHORTCUT_FOLDER', 'ORDINARY_USER_ABSENCE_PROBE', @@ -134,12 +952,15 @@ function Write-CleanupSubstage( 'USER', 'INSTALL_ROOT_FALLBACK', 'PROTOCOL_FALLBACK', + 'APP_PATH_FALLBACK', + 'HKCU_INSTALLED_FALLBACK', 'SHORTCUT_FALLBACK', 'FINAL_AGGREGATION' )][string]$Substage, [ValidateSet('BEGIN','COMPLETE','FAILED','SKIPPED')][string]$Status ) { Write-Host ('PROPR_WINDOWS_INSTALLED_SMOKE:{0}:{1}:{2}' -f $Scope, $Substage, $Status) + [Console]::Out.Flush() } function Stop-SpawnedProcessTree( @@ -494,10 +1315,23 @@ function Test-StartMenuShortcutAsOrdinaryUser( throw 'ordinary-user shortcut probe failed' } -function New-SmokeUserDataDirectory([Security.Principal.SecurityIdentifier]$UserSid) { - $path = Join-Path $machineTemp "propr-desktop-smoke-$([Guid]::NewGuid().ToString('N'))" - New-Item -ItemType Directory -Path $path | Out-Null +function New-SmokeUserDataDirectory( + [Security.Principal.SecurityIdentifier]$UserSid, + [string]$Path +) { + $path = [IO.Path]::GetFullPath($Path) + if ((Split-Path -Leaf $path) -notmatch '^propr-desktop-smoke-[a-f0-9]{32}$' -or + ![string]::Equals( + (Split-Path -Parent $path), $machineTemp, [StringComparison]::OrdinalIgnoreCase)) { + throw 'smoke user-data directory path is invalid' + } + $createdByRun = $false try { + if (Test-Path -LiteralPath $path) { + throw 'refusing to replace a pre-existing smoke user-data directory' + } + New-Item -ItemType Directory -Path $path -ErrorAction Stop | Out-Null + $createdByRun = $true $administratorsSid = New-Object Security.Principal.SecurityIdentifier('S-1-5-32-544') $systemSid = New-Object Security.Principal.SecurityIdentifier('S-1-5-18') $acl = New-Object Security.AccessControl.DirectorySecurity @@ -526,36 +1360,234 @@ function New-SmokeUserDataDirectory([Security.Principal.SecurityIdentifier]$User $invalidRules = @($actualRules | Where-Object { $_.IsInherited -or $_.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow -or ($_.FileSystemRights -band [Security.AccessControl.FileSystemRights]::FullControl) -ne - [Security.AccessControl.FileSystemRights]::FullControl + [Security.AccessControl.FileSystemRights]::FullControl -or + $_.InheritanceFlags -ne $inheritance -or $_.PropagationFlags -ne $propagation }) - if (!$appliedAcl.AreAccessRulesProtected -or $actualRules.Count -ne 3 -or + $appliedOwnerSid = $appliedAcl.GetOwner( + [Security.Principal.SecurityIdentifier]).Value + if ($appliedOwnerSid -cne $administratorsSid.Value -or + !$appliedAcl.AreAccessRulesProtected -or $actualRules.Count -ne 3 -or $invalidRules.Count -ne 0 -or (Compare-Object $expectedSids $actualSids)) { throw 'smoke user-data directory ACL is not restricted to the test user, SYSTEM, and Administrators' } return $path } catch { - Remove-Item -LiteralPath $path -Recurse -Force -ErrorAction SilentlyContinue + if ($createdByRun) { + try { + if ((Test-Path -LiteralPath $path -PathType Container) -and + @(Get-ChildItem -LiteralPath $path -Force -ErrorAction Stop).Count -eq 0) { + Remove-Item -LiteralPath $path -Force -ErrorAction Stop + } + } catch {} + } throw } } -function Remove-SmokeUserDataDirectory([string]$Path) { - if (!$Path) { return } - $fullPath = [IO.Path]::GetFullPath($Path) +function Assert-SmokeAccessControl($Item, $Record, [bool]$Root) { + $userSid = [string]$Record.UserSid + $creatorSid = [string]$Record.CreatorSid + $rootOwnerSid = [string]$Record.RootOwnerSid + if ($userSid -notmatch '^S-\d+(?:-\d+)+$' -or + $creatorSid -notmatch '^S-\d+(?:-\d+)+$' -or + $rootOwnerSid -cne 'S-1-5-32-544') { + throw 'smoke user-data manifest security authority is invalid' + } + $systemSid = 'S-1-5-18' + $expectedAccessSids = @($userSid, $systemSid, $rootOwnerSid) | Sort-Object -Unique + if ($expectedAccessSids.Count -ne 3) { + throw 'smoke user-data manifest security authority is invalid' + } + $acl = Get-Acl -LiteralPath $Item.FullName -ErrorAction Stop + $ownerSid = $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value + $allowedOwnerSids = @($userSid, $creatorSid, $rootOwnerSid) | Sort-Object -Unique + if ($allowedOwnerSids -cnotcontains $ownerSid) { + throw 'smoke user-data object owner is not authorized' + } + $rules = @($acl.Access) + $actualAccessSids = @($rules | ForEach-Object { + ($_.IdentityReference.Translate([Security.Principal.SecurityIdentifier])).Value + }) | Sort-Object -Unique + $fullControl = [Security.AccessControl.FileSystemRights]::FullControl + $expectedInheritance = [Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit' + $invalidRules = if ($Root) { + @($rules | Where-Object { + $_.IsInherited -or + $_.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow -or + ($_.FileSystemRights -band $fullControl) -ne $fullControl -or + $_.InheritanceFlags -ne $expectedInheritance -or + $_.PropagationFlags -ne [Security.AccessControl.PropagationFlags]::None + }) + } else { + $inheritedFlags = if ($Item.PSIsContainer) { + $expectedInheritance + } else { [Security.AccessControl.InheritanceFlags]::None } + @($rules | Where-Object { + !$_.IsInherited -or + $_.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow -or + ($_.FileSystemRights -band $fullControl) -ne $fullControl -or + $_.InheritanceFlags -ne $inheritedFlags -or + $_.PropagationFlags -ne [Security.AccessControl.PropagationFlags]::None + }) + } + if (($Root -and (!$acl.AreAccessRulesProtected -or $ownerSid -cne $rootOwnerSid)) -or + (!$Root -and $acl.AreAccessRulesProtected) -or + $rules.Count -ne 3 -or $invalidRules.Count -ne 0 -or + @(Compare-Object $expectedAccessSids $actualAccessSids).Count -ne 0) { + throw 'smoke user-data object ACL is not authorized' + } +} + +function Assert-OwnedSmokeRoot($Record) { + $fullPath = [IO.Path]::GetFullPath([string]$Record.Path) if ((Split-Path -Leaf $fullPath) -notmatch '^propr-desktop-smoke-[a-f0-9]{32}$' -or ![string]::Equals((Split-Path -Parent $fullPath), $machineTemp, [StringComparison]::OrdinalIgnoreCase)) { - throw 'refusing to clean a directory outside the bounded smoke user-data scope' + throw 'smoke user-data cleanup scope is invalid' } - for ($attempt = 0; $attempt -lt 3; $attempt += 1) { - if (!(Test-Path -LiteralPath $fullPath)) { return } - try { - Remove-Item -LiteralPath $fullPath -Recurse -Force - } catch { - if ($attempt -eq 2) { throw } - Start-Sleep -Milliseconds 250 + $item = Get-Item -LiteralPath $fullPath -Force -ErrorAction Stop + if (!$item.PSIsContainer -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + [string]$Record.Token -notmatch '^[a-f0-9]{32}$') { + throw 'smoke user-data root identity is invalid' + } + $markerPath = Join-Path $fullPath '.propr-installed-app-owner' + $marker = Get-Item -LiteralPath $markerPath -Force -ErrorAction Stop + if (!($marker -is [IO.FileInfo]) -or + ($marker.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'smoke user-data ownership token does not match' + } + $markerIdentity = Get-FileSystemEntryIdentity $marker.FullName $false + $markerStream = [IO.File]::Open( + $markerPath, + [IO.FileMode]::Open, + [IO.FileAccess]::Read, + [IO.FileShare]::Read + ) + try { + if ($markerStream.Length -le 0 -or $markerStream.Length -gt 128) { + throw 'smoke user-data ownership token does not match' + } + $markerBytes = [byte[]]::new([int]$markerStream.Length) + $markerOffset = 0 + while ($markerOffset -lt $markerBytes.Length) { + $markerRead = $markerStream.Read( + $markerBytes, $markerOffset, $markerBytes.Length - $markerOffset) + if ($markerRead -eq 0) { throw 'smoke user-data ownership token does not match' } + $markerOffset += $markerRead + } + if ($markerStream.ReadByte() -ne -1 -or + [Text.Encoding]::ASCII.GetString($markerBytes) -cne [string]$Record.Token) { + throw 'smoke user-data ownership token does not match' + } + } finally { + $markerStream.Dispose() + } + Assert-SmokeAccessControl $item $Record $true + Assert-SmokeAccessControl $marker $Record $false + if ((Get-FileSystemEntryIdentity $marker.FullName $false) -cne $markerIdentity) { + throw 'smoke user-data ownership token identity changed' + } + return $item +} + +function Promote-SmokeOwnershipRecord($Record) { + if ($null -eq $testUserSid -or + [string]$Record.UserSid -cne [string]$testUserSid.Value) { + throw 'smoke user-data SID is not the exact run-owned user SID' + } + if (!(Test-Path -LiteralPath ([string]$Record.Path))) { return $false } + $root = Assert-OwnedSmokeRoot $Record + $identity = Get-FileSystemEntryIdentity $root.FullName $true + if ([bool]$Record.Provisional) { + $Record.Identity = $identity + $Record.Provisional = $false + Write-OwnershipManifest + } elseif ([string]$Record.Identity -notmatch '^[a-f0-9]{24}$' -or + [string]$Record.Identity -cne $identity) { + throw 'smoke user-data root identity does not match' + } + return $true +} + +function Remove-SmokeUserDataDirectory($Record) { + if ($null -eq $Record -or !(Test-Path -LiteralPath ([string]$Record.Path))) { return } + if ([bool]$Record.Provisional) { + throw 'provisional smoke user-data authority was not durably promoted' + } + $root = Assert-OwnedSmokeRoot $Record + if ([string]$Record.Identity -notmatch '^[a-f0-9]{24}$' -or + (Get-FileSystemEntryIdentity $root.FullName $true) -cne [string]$Record.Identity) { + throw 'smoke user-data root identity does not match' + } + $rootPath = $root.FullName.TrimEnd('\') + $pending = [Collections.Generic.Queue[object]]::new() + $pending.Enqueue([PSCustomObject]@{ + Path = $root.FullName + Identity = [string]$Record.Identity + Root = $true + }) + $entries = [Collections.Generic.List[object]]::new() + while ($pending.Count -ne 0) { + $queuedDirectory = $pending.Dequeue() + $directory = Get-Item -LiteralPath $queuedDirectory.Path -Force -ErrorAction Stop + Assert-SmokeAccessControl $directory $Record ([bool]$queuedDirectory.Root) + if ((Get-FileSystemEntryIdentity $directory.FullName $true) -cne + [string]$queuedDirectory.Identity) { + throw 'smoke user-data directory identity changed during traversal' + } + foreach ($child in @(Get-ChildItem -LiteralPath $directory.FullName -Force -ErrorAction Stop)) { + if ($entries.Count -ge 50000) { throw 'smoke user-data cleanup entry bound was exceeded' } + $childPath = [IO.Path]::GetFullPath($child.FullName) + if (!$childPath.StartsWith("$rootPath\", [StringComparison]::OrdinalIgnoreCase) -or + ($child.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'smoke user-data descendant scope is invalid' + } + Assert-SmokeAccessControl $child $Record $false + $identity = Get-FileSystemEntryIdentity $childPath ([bool]$child.PSIsContainer) + $entries.Add([PSCustomObject]@{ + Path = $childPath + Directory = [bool]$child.PSIsContainer + Identity = $identity + }) + if ($child.PSIsContainer) { + $pending.Enqueue([PSCustomObject]@{ + Path = $childPath + Identity = $identity + Root = $false + }) + } } } - if (Test-Path -LiteralPath $fullPath) { throw 'smoke user-data directory cleanup did not complete' } + + foreach ($entry in @($entries | Where-Object { !$_.Directory })) { + $item = Get-Item -LiteralPath $entry.Path -Force -ErrorAction Stop + Assert-SmokeAccessControl $item $Record $false + if ((Get-FileSystemEntryIdentity $entry.Path $false) -cne [string]$entry.Identity) { + throw 'smoke user-data file identity changed during cleanup' + } + Remove-Item -LiteralPath $entry.Path -Force -ErrorAction Stop + } + foreach ($entry in @($entries | Where-Object { $_.Directory } | + Sort-Object { ([string]$_.Path).Length } -Descending)) { + $item = Get-Item -LiteralPath $entry.Path -Force -ErrorAction Stop + Assert-SmokeAccessControl $item $Record $false + if ((Get-FileSystemEntryIdentity $entry.Path $true) -cne [string]$entry.Identity -or + @(Get-ChildItem -LiteralPath $entry.Path -Force -ErrorAction Stop).Count -ne 0) { + throw 'smoke user-data directory identity changed or is not empty' + } + Remove-Item -LiteralPath $entry.Path -Force -ErrorAction Stop + } + $root = Get-Item -LiteralPath $rootPath -Force -ErrorAction Stop + if (!$root.PSIsContainer -or + ($root.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'smoke user-data root identity changed during cleanup' + } + Assert-SmokeAccessControl $root $Record $true + if ((Get-FileSystemEntryIdentity $root.FullName $true) -cne [string]$Record.Identity -or + @(Get-ChildItem -LiteralPath $root.FullName -Force -ErrorAction Stop).Count -ne 0) { + throw 'smoke user-data root changed or is not empty' + } + Remove-Item -LiteralPath $root.FullName -Force -ErrorAction Stop } function Get-SmokeEventEvidence( @@ -707,13 +1739,209 @@ try { Write-Stage 'INSTALL' 'BEGIN' try { $installAttempted = $true + $ownershipState.InstallAttempted = $true + $ownershipState.MsiTransactionState = 'PENDING' + # PENDING is a recovery signal only. It never authorizes MSI uninstall or + # path-based reconstruction/deletion; only a durable transaction receipt can. + $ownershipState.Directories = @( + [ordered]@{ + Kind = 'INSTALL_ROOT'; Path = $installRoot; Owned = $true + Token = $null; Identity = $null; TreeIdentity = $null; Provisional = $true + }, + [ordered]@{ + Kind = 'SHORTCUT_FOLDER'; Path = $startMenuShortcutFolder + Owned = $true; Token = $null; Identity = $null; TreeIdentity = $null + Provisional = $true + } + ) + $ownershipState.Files = @([ordered]@{ + Kind = 'SHORTCUT_FILE'; Path = $startMenuShortcut; Owned = $true + Token = $null; Identity = $null; EntryIdentity = $null; Provisional = $true + }) + $ownershipState.RegistryKeys = @( + [ordered]@{ + Kind = 'PROTOCOL'; Path = $protocolRegistryPath + Owned = $true; Token = $null; Identity = $null; Provisional = $true + }, + [ordered]@{ + Kind = 'APP_PATH'; Path = $appPathsRegistryPath + Owned = $true; Token = $null; Identity = $null; Provisional = $true + } + ) + $ownershipState.RegistryValues = @([ordered]@{ + Kind = 'HKCU_INSTALLED' + Path = $hkcuDesktopRegistryPath + Name = $hkcuInstalledValueName + Owned = $true + Provisional = $true + BaselineKeyExisted = $hkcuDesktopKeyExistedBeforeInstall + BaselineValueExisted = $hkcuInstalledValueExistedBeforeInstall + BaselineValueKind = $hkcuInstalledBaselineKind + BaselineValueData = $hkcuInstalledBaselineData + IdentityValueKind = $null + IdentityValueData = $null + KeyCreatedByRun = $false + }) + Write-OwnershipManifest + $msiTransactionFailure = $null try { - Invoke-Msi @('/i', "`"$installerPath`"", '/qn', '/norestart') 'machine install' - } finally { - $startMenuShortcutCreatedByRun = - !$startMenuShortcutExistedBeforeInstall -and (Test-Path -LiteralPath $startMenuShortcut) - $startMenuShortcutFolderCreatedByRun = - !$startMenuShortcutFolderExistedBeforeInstall -and (Test-Path -LiteralPath $startMenuShortcutFolder) + Invoke-BoundedExternalOperation ` + -Stage 'INSTALL' ` + -Substage 'MSI_INSTALL' ` + -TimeoutMilliseconds ($msiTimeoutMilliseconds + $terminationTimeoutMilliseconds + 5000) ` + -Operation { + Assert-InstallerArtifactAuthority + Invoke-Msi @('/i', "`"$installerPath`"", '/qn', '/norestart') 'machine install' + $script:msiInstallCompleted = $true + } + } catch { + $msiTransactionFailure = $_ + } + if ($null -ne $msiTransactionFailure) { + Invoke-BoundedExternalOperation ` + -Stage 'INSTALL' ` + -Substage 'OWNERSHIP_CAPTURE' ` + -TimeoutMilliseconds $externalOperationTimeoutMilliseconds ` + -Operation { + Wait-ExactCleanMsiBaselineAfterRollback + $ownershipState.Directories = @() + $ownershipState.Files = @() + $ownershipState.RegistryKeys = @() + $ownershipState.RegistryValues = @([ordered]@{ + Kind = 'HKCU_INSTALLED'; Path = $hkcuDesktopRegistryPath + Name = $hkcuInstalledValueName; Owned = $false; Provisional = $false + BaselineKeyExisted = $hkcuDesktopKeyExistedBeforeInstall + BaselineValueExisted = $hkcuInstalledValueExistedBeforeInstall + BaselineValueKind = $hkcuInstalledBaselineKind + BaselineValueData = $hkcuInstalledBaselineData + IdentityValueKind = $null; IdentityValueData = $null + KeyCreatedByRun = $false + }) + $ownershipState.MsiTransactionState = 'ROLLED_BACK_CLEAN' + Write-OwnershipManifest + } + throw $msiTransactionFailure + } else { + Invoke-BoundedExternalOperation ` + -Stage 'INSTALL' ` + -Substage 'OWNERSHIP_CAPTURE' ` + -TimeoutMilliseconds $externalOperationTimeoutMilliseconds ` + -Operation { + if (!$script:msiInstallCompleted) { + throw 'MSI transaction commit status is unavailable' + } + $script:installRootCreatedByRun = + !$installRootExistedBeforeInstall -and (Test-Path -LiteralPath $installRoot) + $script:protocolCreatedByRun = + !$protocolExistedBeforeInstall -and + (Test-Path -LiteralPath $protocolRegistryPath) + $script:appPathsCreatedByRun = + !$appPathsExistedBeforeInstall -and (Test-Path -LiteralPath $appPathsRegistryPath) + $script:hkcuDesktopKeyCreatedByRun = + !$hkcuDesktopKeyExistedBeforeInstall -and + (Test-Path -LiteralPath $hkcuDesktopRegistryPath) + $script:startMenuShortcutCreatedByRun = + !$startMenuShortcutExistedBeforeInstall -and (Test-Path -LiteralPath $startMenuShortcut) + $script:startMenuShortcutFolderCreatedByRun = + !$startMenuShortcutFolderExistedBeforeInstall -and + (Test-Path -LiteralPath $startMenuShortcutFolder) + if (!$script:installRootCreatedByRun -or !$script:protocolCreatedByRun -or + !$script:appPathsCreatedByRun -or !$script:startMenuShortcutCreatedByRun -or + !$script:startMenuShortcutFolderCreatedByRun) { + throw 'MSI commit did not create every canonical managed resource' + } + $ownedDirectories = @() + if ($script:installRootCreatedByRun) { + $script:installRootOwnedIdentity = Get-DirectoryIdentity $installRoot + $script:installRootOwnedTreeIdentity = Get-FileSystemTreeIdentity $installRoot + if (!$script:installRootOwnedIdentity -or !$script:installRootOwnedTreeIdentity) { + throw 'installed tree identity could not be captured' + } + $ownedDirectories += [ordered]@{ + Kind = 'INSTALL_ROOT'; Path = $installRoot; Owned = $true + Token = $null; Identity = $script:installRootOwnedIdentity + TreeIdentity = $script:installRootOwnedTreeIdentity + Provisional = $false + } + } + if ($script:startMenuShortcutFolderCreatedByRun) { + $script:shortcutFolderOwnedIdentity = Get-DirectoryIdentity $startMenuShortcutFolder + $script:shortcutFolderOwnedTreeIdentity = + Get-FileSystemTreeIdentity $startMenuShortcutFolder + if (!$script:shortcutFolderOwnedIdentity -or + !$script:shortcutFolderOwnedTreeIdentity) { + throw 'installed shortcut folder identity could not be captured' + } + $ownedDirectories += [ordered]@{ + Kind = 'SHORTCUT_FOLDER'; Path = $startMenuShortcutFolder + Owned = $true; Token = $null; Identity = $script:shortcutFolderOwnedIdentity + TreeIdentity = $script:shortcutFolderOwnedTreeIdentity + Provisional = $false + } + } + $ownershipState.Directories = $ownedDirectories + $ownershipState.Files = if ($script:startMenuShortcutCreatedByRun) { + $script:shortcutOwnedIdentity = Get-FileIdentity $startMenuShortcut + $script:shortcutOwnedEntryIdentity = + Get-FileSystemEntryIdentity $startMenuShortcut $false + if (!$script:shortcutOwnedIdentity -or !$script:shortcutOwnedEntryIdentity) { + throw 'installed shortcut identity could not be captured' + } + @([ordered]@{ + Kind = 'SHORTCUT_FILE'; Path = $startMenuShortcut; Owned = $true + Token = $null; Identity = $script:shortcutOwnedIdentity + EntryIdentity = $script:shortcutOwnedEntryIdentity + Provisional = $false + }) + } else { @() } + $ownedRegistryKeys = @() + if ($script:protocolCreatedByRun) { + $script:protocolOwnedIdentity = Get-RegistryTreeIdentity $protocolRegistryPath + if ([string]$script:protocolOwnedIdentity -notmatch '^[a-f0-9]{64}$') { + throw 'installed protocol identity could not be captured' + } + $ownedRegistryKeys += [ordered]@{ + Kind = 'PROTOCOL'; Path = $protocolRegistryPath + Owned = $true; Token = $null; Identity = $script:protocolOwnedIdentity + Provisional = $false + } + } + if ($script:appPathsCreatedByRun) { + $script:appPathsOwnedIdentity = Get-RegistryTreeIdentity $appPathsRegistryPath + if ([string]$script:appPathsOwnedIdentity -notmatch '^[a-f0-9]{64}$') { + throw 'installed App Paths identity could not be captured' + } + $ownedRegistryKeys += [ordered]@{ + Kind = 'APP_PATH'; Path = $appPathsRegistryPath + Owned = $true; Token = $null; Identity = $script:appPathsOwnedIdentity + Provisional = $false + } + } + $ownershipState.RegistryKeys = $ownedRegistryKeys + $ownedHkcuInstalled = Get-RegistryValueSnapshot ` + $hkcuDesktopRegistryPath $hkcuInstalledValueName + if (!$ownedHkcuInstalled.Exists) { + throw 'installed current-user value identity could not be captured' + } + $script:hkcuInstalledOwnedKind = $ownedHkcuInstalled.Kind + $script:hkcuInstalledOwnedData = $ownedHkcuInstalled.Data + $ownershipState.RegistryValues = @([ordered]@{ + Kind = 'HKCU_INSTALLED' + Path = $hkcuDesktopRegistryPath + Name = $hkcuInstalledValueName + Owned = $true + Provisional = $false + BaselineKeyExisted = $hkcuDesktopKeyExistedBeforeInstall + BaselineValueExisted = $hkcuInstalledValueExistedBeforeInstall + BaselineValueKind = $hkcuInstalledBaselineKind + BaselineValueData = $hkcuInstalledBaselineData + IdentityValueKind = $script:hkcuInstalledOwnedKind + IdentityValueData = $script:hkcuInstalledOwnedData + KeyCreatedByRun = $script:hkcuDesktopKeyCreatedByRun + }) + $ownershipState.MsiTransactionState = 'COMMITTED' + Write-OwnershipManifest + } } Write-Stage 'INSTALL' 'COMPLETE' } catch { @@ -723,36 +1951,68 @@ try { Write-Stage 'VALIDATION' 'BEGIN' try { - if (!(Test-Path -LiteralPath $application -PathType Leaf)) { - throw 'machine installer did not install the canonical application' - } - $forbidden = @(Get-ChildItem -LiteralPath $installRoot -Recurse -Force | Where-Object { - $_.Name -match '^propr-windows-(authority|launcher|bootstrap)' -or - $_.Name -in @('windows-authority', 'windows-update-authority') - }) - if ($forbidden.Count -ne 0) { throw 'installed MVP contains a deferred Windows update authority resource' } + Invoke-BoundedExternalOperation 'VALIDATION' 'INSTALL_TREE_SCAN' ` + $recursiveOperationTimeoutMilliseconds { + if (!(Test-Path -LiteralPath $application -PathType Leaf)) { + throw 'machine installer did not install the canonical application' + } + $forbidden = @(Get-ChildItem -LiteralPath $installRoot -Recurse -Force | Where-Object { + $_.Name -match '^propr-windows-(authority|launcher|bootstrap)' -or + $_.Name -in @('windows-authority', 'windows-update-authority') + }) + if ($forbidden.Count -ne 0) { + throw 'installed MVP contains a deferred Windows update authority resource' + } + } - $image = New-Object byte[] 4096 - $stream = [IO.File]::OpenRead($application) - try { $imageLength = $stream.Read($image, 0, $image.Length) } finally { $stream.Dispose() } - $pe = if ($imageLength -ge 64) { [BitConverter]::ToUInt32($image, 0x3c) } else { 0 } - $expectedMachine = if ($Architecture -eq 'arm64') { 0xaa64 } else { 0x8664 } - if ($imageLength -lt 512 -or [BitConverter]::ToUInt16($image, 0) -ne 0x5a4d -or - $pe + 6 -gt $imageLength -or [Text.Encoding]::ASCII.GetString($image, [int]$pe, 4) -cne "PE`0`0" -or - [BitConverter]::ToUInt16($image, [int]$pe + 4) -ne $expectedMachine) { - throw 'installed application architecture does not match the matrix target' - } + Invoke-BoundedExternalOperation 'VALIDATION' 'APPLICATION_IMAGE' ` + $externalOperationTimeoutMilliseconds { + $image = New-Object byte[] 4096 + $stream = [IO.File]::OpenRead($application) + try { $imageLength = $stream.Read($image, 0, $image.Length) } finally { $stream.Dispose() } + $pe = if ($imageLength -ge 64) { [BitConverter]::ToUInt32($image, 0x3c) } else { 0 } + $expectedMachine = if ($Architecture -eq 'arm64') { 0xaa64 } else { 0x8664 } + if ($imageLength -lt 512 -or [BitConverter]::ToUInt16($image, 0) -ne 0x5a4d -or + $pe + 6 -gt $imageLength -or + [Text.Encoding]::ASCII.GetString($image, [int]$pe, 4) -cne "PE`0`0" -or + [BitConverter]::ToUInt16($image, [int]$pe + 4) -ne $expectedMachine) { + throw 'installed application architecture does not match the matrix target' + } + } - $protocolCommand = (Get-Item -LiteralPath 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr\shell\open\command').GetValue('') - if ($protocolCommand -cne "`"$application`" `"%1`"") { - throw 'machine installer did not register canonical ProPR Connect protocol discovery' - } - $shortcutItem = Get-Item -LiteralPath $startMenuShortcut -Force -ErrorAction Stop - if (!($shortcutItem -is [IO.FileInfo]) -or - ($shortcutItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or - $shortcutItem.Length -le 0) { - throw 'machine installer did not create the common Start Menu shortcut' - } + Invoke-BoundedExternalOperation 'VALIDATION' 'PROTOCOL_ASSERTION' ` + $externalOperationTimeoutMilliseconds { + $protocolCommand = (Get-Item -LiteralPath ` + "$protocolRegistryPath\shell\open\command").GetValue('') + if ($protocolCommand -cne "`"$application`" `"%1`"") { + throw 'machine installer did not register canonical ProPR Connect protocol discovery' + } + } + + Invoke-BoundedExternalOperation 'VALIDATION' 'APP_PATH_ASSERTION' ` + $externalOperationTimeoutMilliseconds { + $appPathApplication = (Get-Item -LiteralPath $appPathsRegistryPath).GetValue('') + if ($appPathApplication -cne $application) { + throw 'machine installer did not register canonical executable discovery' + } + } + + Invoke-BoundedExternalOperation 'VALIDATION' 'HKCU_INSTALLED_ASSERTION' ` + $externalOperationTimeoutMilliseconds { + if (!(Test-MsiInstalledValue $hkcuDesktopRegistryPath $hkcuInstalledValueName)) { + throw 'machine installer did not author the current-user installed value' + } + } + + Invoke-BoundedExternalOperation 'VALIDATION' 'SHORTCUT_ASSERTION' ` + $externalOperationTimeoutMilliseconds { + $shortcutItem = Get-Item -LiteralPath $startMenuShortcut -Force -ErrorAction Stop + if (!($shortcutItem -is [IO.FileInfo]) -or + ($shortcutItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + $shortcutItem.Length -le 0) { + throw 'machine installer did not create the common Start Menu shortcut' + } + } Write-Stage 'VALIDATION' 'COMPLETE' } catch { Write-Stage 'VALIDATION' 'FAILED' @@ -761,16 +2021,70 @@ try { Write-Stage 'USER_SETUP' 'BEGIN' try { - New-LocalUser -Name $testUser -Password $password -AccountNeverExpires -PasswordNeverExpires | Out-Null - $testUserSid = (Get-LocalUser -Name $testUser).SID - $smokeUserDataDirectory = New-SmokeUserDataDirectory $testUserSid - Test-StartMenuShortcutAsOrdinaryUser ` - -Credential $credential ` - -Domain $env:COMPUTERNAME ` - -UserName $testUser ` - -UserSid $testUserSid ` - -ShortcutPath $startMenuShortcut ` - -ExpectedPresent $true + Invoke-BoundedExternalOperation 'USER_SETUP' 'USER_CREATE' ` + $externalOperationTimeoutMilliseconds { + if (Get-LocalUser -Name $testUser -ErrorAction SilentlyContinue) { + throw 'refusing to replace a pre-existing local user' + } + $userOwnershipMarker = + "prpr-own-$([Guid]::NewGuid().ToString('N'))" + $provisionalUser = [ordered]@{ + Name = $testUser + Sid = $null + Owned = $true + Provisional = $true + OwnershipMarker = $userOwnershipMarker + } + $ownershipState.Users = @($provisionalUser) + Write-OwnershipManifest + New-LocalUser -Name $testUser -Password $password ` + -Description $userOwnershipMarker ` + -AccountNeverExpires -PasswordNeverExpires | Out-Null + $script:testUserCreatedByRun = $true + $script:testUserSid = (Get-LocalUser -Name $testUser -ErrorAction Stop).SID + $provisionalUser.Sid = $script:testUserSid.Value + $provisionalUser.Provisional = $false + Write-OwnershipManifest + } + $testUserSid = Invoke-BoundedExternalOperation 'USER_SETUP' 'USER_SID' ` + $externalOperationTimeoutMilliseconds { + $script:testUserSid + } + $smokeUserDataCandidate = Join-Path ` + $machineTemp "propr-desktop-smoke-$([Guid]::NewGuid().ToString('N'))" + if (Test-Path -LiteralPath $smokeUserDataCandidate) { + throw 'refusing to replace a pre-existing smoke user-data directory' + } + $smokeOwnershipRecord = [ordered]@{ + Kind = 'SMOKE_DATA'; Path = $smokeUserDataCandidate + Owned = $true; Token = $ownershipToken; Identity = $null; Provisional = $true + UserSid = $testUserSid.Value + CreatorSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value + RootOwnerSid = 'S-1-5-32-544' + } + $ownershipState.Directories = @($ownershipState.Directories) + @($smokeOwnershipRecord) + Write-OwnershipManifest + $smokeUserDataDirectory = Invoke-BoundedExternalOperation ` + 'USER_SETUP' 'SMOKE_DATA_CREATE' $recursiveOperationTimeoutMilliseconds { + $ownedSmokeDirectory = New-SmokeUserDataDirectory $testUserSid $smokeUserDataCandidate + Write-DurableOwnershipToken ` + -Path (Join-Path $ownedSmokeDirectory '.propr-installed-app-owner') ` + -Token $ownershipToken + if (!(Promote-SmokeOwnershipRecord $smokeOwnershipRecord)) { + throw 'smoke user-data ownership promotion did not complete' + } + $ownedSmokeDirectory + } + Invoke-BoundedExternalOperation ` + 'USER_SETUP' 'SHORTCUT_PRESENT_PROBE' $externalOperationTimeoutMilliseconds { + Test-StartMenuShortcutAsOrdinaryUser ` + -Credential $credential ` + -Domain $env:COMPUTERNAME ` + -UserName $testUser ` + -UserSid $testUserSid ` + -ShortcutPath $startMenuShortcut ` + -ExpectedPresent $true + } Write-Stage 'USER_SETUP' 'COMPLETE' } catch { Write-Stage 'USER_SETUP' 'FAILED' @@ -786,18 +2100,21 @@ try { Write-Stage 'APP_LAUNCH' 'BEGIN' $applicationLaunch = $null try { - $applicationLaunch = Start-AlternateCredentialApplication ` - -FilePath $application ` - -Arguments $arguments ` - -Credential $credential ` - -Domain $env:COMPUTERNAME ` - -UserName $testUser ` - -WorkingDirectory $env:ProgramFiles ` - -SmokeDirectory $smokeUserDataDirectory ` - -WindowsDirectory $windowsDirectory ` - -StandardOutputPath (Join-Path $smokeUserDataDirectory 'application.stdout.log') ` - -StandardErrorPath (Join-Path $smokeUserDataDirectory 'application.stderr.log') ` - -Operation 'ordinary-user installed application launch/render/profile smoke' + $applicationLaunch = Invoke-BoundedExternalOperation ` + 'APP_LAUNCH' 'ALTERNATE_USER_START' $alternateUserLaunchTimeoutMilliseconds { + Start-AlternateCredentialApplication ` + -FilePath $application ` + -Arguments $arguments ` + -Credential $credential ` + -Domain $env:COMPUTERNAME ` + -UserName $testUser ` + -WorkingDirectory $env:ProgramFiles ` + -SmokeDirectory $smokeUserDataDirectory ` + -WindowsDirectory $windowsDirectory ` + -StandardOutputPath (Join-Path $smokeUserDataDirectory 'application.stdout.log') ` + -StandardErrorPath (Join-Path $smokeUserDataDirectory 'application.stderr.log') ` + -Operation 'ordinary-user installed application launch/render/profile smoke' + } Write-Stage 'APP_LAUNCH' 'COMPLETE' } catch { Write-Stage 'APP_LAUNCH' 'FAILED' @@ -807,17 +2124,24 @@ try { try { $waitFailure = $null try { - [void](Wait-BoundedProcess ` - -Process $applicationLaunch.Process ` - -TimeoutMilliseconds $applicationTimeoutMilliseconds ` - -AllowedExitCodes @(0) ` - -Operation 'ordinary-user installed application launch/render/profile smoke') + Invoke-BoundedExternalOperation ` + 'APP_EXIT' 'APPLICATION_WAIT' ` + ($applicationTimeoutMilliseconds + $terminationTimeoutMilliseconds + 5000) { + [void](Wait-BoundedProcess ` + -Process $applicationLaunch.Process ` + -TimeoutMilliseconds $applicationTimeoutMilliseconds ` + -AllowedExitCodes @(0) ` + -Operation 'ordinary-user installed application launch/render/profile smoke') + } } catch { $waitFailure = $_ } finally { try { - Close-RedirectedApplicationStreams $applicationLaunch ` - 'ordinary-user installed application launch/render/profile smoke' + Invoke-BoundedExternalOperation ` + 'APP_EXIT' 'STREAM_DRAIN' ($redirectedStreamDrainTimeoutMilliseconds + 5000) { + Close-RedirectedApplicationStreams $applicationLaunch ` + 'ordinary-user installed application launch/render/profile smoke' + } } catch { if ($null -eq $waitFailure) { $waitFailure = $_ } } finally { @@ -825,7 +2149,10 @@ try { $applicationLaunch = $null } } - $smokeEvidence = Get-SmokeEventEvidence $smokeUserDataDirectory $testUserSid + $smokeEvidence = Invoke-BoundedExternalOperation ` + 'APP_EXIT' 'EVIDENCE_INSPECTION' $externalOperationTimeoutMilliseconds { + Get-SmokeEventEvidence $smokeUserDataDirectory $testUserSid + } if ($null -ne $waitFailure) { throw $waitFailure } if (@($requiredSmokeEvents | Where-Object { !$smokeEvidence[$_] }).Count -ne 0) { throw 'SMOKE_REQUIRED_EVENTS_MISSING' @@ -836,8 +2163,13 @@ try { throw } finally { if ($null -ne $applicationLaunch) { - try { Close-RedirectedApplicationStreams $applicationLaunch ` - 'ordinary-user installed application launch/render/profile smoke' } finally { + try { + Invoke-BoundedExternalOperation ` + 'APP_EXIT' 'STREAM_DRAIN' ($redirectedStreamDrainTimeoutMilliseconds + 5000) { + Close-RedirectedApplicationStreams $applicationLaunch ` + 'ordinary-user installed application launch/render/profile smoke' + } + } finally { $applicationLaunch.Process.Dispose() } } @@ -847,22 +2179,55 @@ try { throw } finally { $cleanupFailed = $false - if ($installAttempted) { + $profileCleanupFailed = $false + if ($installerArtifactAuthorityValid) { + Assert-InstallerArtifactAuthority + if ($installAttempted -and + [string]$ownershipState.MsiTransactionState -ceq 'COMMITTED') { Write-Stage 'UNINSTALL' 'BEGIN' $uninstallFailed = $false Write-CleanupSubstage 'UNINSTALL' 'MSI_UNINSTALL' 'BEGIN' try { - Invoke-Msi @('/x', "`"$installerPath`"", '/qn', '/norestart') 'machine uninstall' + Invoke-BoundedExternalOperation ` + 'UNINSTALL' 'MSI_UNINSTALL' ` + ($msiTimeoutMilliseconds + $terminationTimeoutMilliseconds + 5000) { + Assert-MsiManagedFileSystemAuthority + if ($protocolCreatedByRun -and (Test-Path -LiteralPath $protocolRegistryPath) -and + (!$protocolOwnedIdentity -or + (Get-RegistryTreeIdentity $protocolRegistryPath) -cne $protocolOwnedIdentity)) { + throw 'refusing to uninstall over protocol metadata with a mismatched ownership identity' + } + if ($appPathsCreatedByRun -and (Test-Path -LiteralPath $appPathsRegistryPath) -and + (!$appPathsOwnedIdentity -or + (Get-RegistryTreeIdentity $appPathsRegistryPath) -cne $appPathsOwnedIdentity)) { + throw 'refusing to uninstall over executable metadata with a mismatched ownership identity' + } + if (!(Test-MsiInstalledValue $hkcuDesktopRegistryPath $hkcuInstalledValueName)) { + throw 'refusing to uninstall over current-user metadata with mismatched ownership' + } + Assert-InstallerArtifactAuthority + Invoke-Msi @( + '/x', [string]$ownershipState.InstallerProductCode, '/qn', '/norestart' + ) 'machine uninstall' + } Write-CleanupSubstage 'UNINSTALL' 'MSI_UNINSTALL' 'COMPLETE' } catch { Write-CleanupSubstage 'UNINSTALL' 'MSI_UNINSTALL' 'FAILED' $uninstallFailed = $true } + if (!$installerArtifactAuthorityValid) { + throw 'installer authority changed before uninstall; ACTIVE recovery authority retained' + } Write-CleanupSubstage 'UNINSTALL' 'INSTALL_TREE' 'BEGIN' try { - if (Test-Path -LiteralPath $installRoot) { throw 'machine uninstall left the canonical install tree behind' } + Invoke-BoundedExternalOperation ` + 'UNINSTALL' 'INSTALL_TREE_ASSERTION' $externalOperationTimeoutMilliseconds { + if (Test-Path -LiteralPath $installRoot) { + throw 'machine uninstall left the canonical install tree behind' + } + } Write-CleanupSubstage 'UNINSTALL' 'INSTALL_TREE' 'COMPLETE' } catch { Write-CleanupSubstage 'UNINSTALL' 'INSTALL_TREE' 'FAILED' @@ -871,20 +2236,55 @@ try { Write-CleanupSubstage 'UNINSTALL' 'PROTOCOL' 'BEGIN' try { - if (Test-Path -LiteralPath 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr') { - throw 'machine uninstall left protocol discovery metadata behind' - } + Invoke-BoundedExternalOperation ` + 'UNINSTALL' 'PROTOCOL_ABSENCE_ASSERTION' $externalOperationTimeoutMilliseconds { + if (Test-Path -LiteralPath $protocolRegistryPath) { + throw 'machine uninstall left protocol discovery metadata behind' + } + } Write-CleanupSubstage 'UNINSTALL' 'PROTOCOL' 'COMPLETE' } catch { Write-CleanupSubstage 'UNINSTALL' 'PROTOCOL' 'FAILED' $uninstallFailed = $true } + Write-CleanupSubstage 'UNINSTALL' 'APP_PATH' 'BEGIN' + try { + Invoke-BoundedExternalOperation ` + 'UNINSTALL' 'APP_PATH_ABSENCE_ASSERTION' $externalOperationTimeoutMilliseconds { + if (Test-Path -LiteralPath $appPathsRegistryPath) { + throw 'machine uninstall left executable discovery metadata behind' + } + } + Write-CleanupSubstage 'UNINSTALL' 'APP_PATH' 'COMPLETE' + } catch { + Write-CleanupSubstage 'UNINSTALL' 'APP_PATH' 'FAILED' + $uninstallFailed = $true + } + + Write-CleanupSubstage 'UNINSTALL' 'HKCU_INSTALLED' 'BEGIN' + try { + Invoke-BoundedExternalOperation ` + 'UNINSTALL' 'HKCU_INSTALLED_ABSENCE_ASSERTION' $externalOperationTimeoutMilliseconds { + if ((Get-RegistryValueSnapshot ` + $hkcuDesktopRegistryPath $hkcuInstalledValueName).Exists) { + throw 'machine uninstall left current-user installed metadata behind' + } + } + Write-CleanupSubstage 'UNINSTALL' 'HKCU_INSTALLED' 'COMPLETE' + } catch { + Write-CleanupSubstage 'UNINSTALL' 'HKCU_INSTALLED' 'FAILED' + $uninstallFailed = $true + } + Write-CleanupSubstage 'UNINSTALL' 'SHORTCUT_FILE' 'BEGIN' try { - if (Test-Path -LiteralPath $startMenuShortcut) { - throw 'machine uninstall left the common Start Menu shortcut behind' - } + Invoke-BoundedExternalOperation ` + 'UNINSTALL' 'SHORTCUT_FILE_ASSERTION' $externalOperationTimeoutMilliseconds { + if (Test-Path -LiteralPath $startMenuShortcut) { + throw 'machine uninstall left the common Start Menu shortcut behind' + } + } Write-CleanupSubstage 'UNINSTALL' 'SHORTCUT_FILE' 'COMPLETE' } catch { Write-CleanupSubstage 'UNINSTALL' 'SHORTCUT_FILE' 'FAILED' @@ -893,9 +2293,12 @@ try { Write-CleanupSubstage 'UNINSTALL' 'SHORTCUT_FOLDER' 'BEGIN' try { - if (Test-Path -LiteralPath $startMenuShortcutFolder) { - throw 'machine uninstall left the common Start Menu folder behind' - } + Invoke-BoundedExternalOperation ` + 'UNINSTALL' 'SHORTCUT_FOLDER_ASSERTION' $externalOperationTimeoutMilliseconds { + if (Test-Path -LiteralPath $startMenuShortcutFolder) { + throw 'machine uninstall left the common Start Menu folder behind' + } + } Write-CleanupSubstage 'UNINSTALL' 'SHORTCUT_FOLDER' 'COMPLETE' } catch { Write-CleanupSubstage 'UNINSTALL' 'SHORTCUT_FOLDER' 'FAILED' @@ -905,13 +2308,16 @@ try { if ($null -ne $testUserSid) { Write-CleanupSubstage 'UNINSTALL' 'ORDINARY_USER_ABSENCE_PROBE' 'BEGIN' try { - Test-StartMenuShortcutAsOrdinaryUser ` - -Credential $credential ` - -Domain $env:COMPUTERNAME ` - -UserName $testUser ` - -UserSid $testUserSid ` - -ShortcutPath $startMenuShortcut ` - -ExpectedPresent $false + Invoke-BoundedExternalOperation ` + 'UNINSTALL' 'SHORTCUT_ABSENCE_PROBE' $externalOperationTimeoutMilliseconds { + Test-StartMenuShortcutAsOrdinaryUser ` + -Credential $credential ` + -Domain $env:COMPUTERNAME ` + -UserName $testUser ` + -UserSid $testUserSid ` + -ShortcutPath $startMenuShortcut ` + -ExpectedPresent $false + } Write-CleanupSubstage 'UNINSTALL' 'ORDINARY_USER_ABSENCE_PROBE' 'COMPLETE' } catch { Write-CleanupSubstage 'UNINSTALL' 'ORDINARY_USER_ABSENCE_PROBE' 'FAILED' @@ -932,7 +2338,10 @@ try { Write-Stage 'CLEANUP' 'BEGIN' Write-CleanupSubstage 'CLEANUP' 'SMOKE_DATA' 'BEGIN' try { - Remove-SmokeUserDataDirectory $smokeUserDataDirectory + Invoke-BoundedExternalOperation ` + 'CLEANUP' 'SMOKE_DATA_REMOVE' $recursiveOperationTimeoutMilliseconds { + Remove-SmokeUserDataDirectory $smokeOwnershipRecord + } Write-CleanupSubstage 'CLEANUP' 'SMOKE_DATA' 'COMPLETE' } catch { Write-CleanupSubstage 'CLEANUP' 'SMOKE_DATA' 'FAILED' @@ -941,22 +2350,109 @@ try { Write-CleanupSubstage 'CLEANUP' 'PROFILE' 'BEGIN' try { - if ($null -ne $testUserSid) { - $profiles = @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop | Where-Object { - $_.SID -eq $testUserSid.Value + if ($testUserCreatedByRun -and $null -ne $testUserSid) { + $profiles = @(Invoke-BoundedExternalOperation ` + 'CLEANUP' 'PROFILE_LOOKUP' $externalOperationTimeoutMilliseconds { + @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop | Where-Object { + $_.SID -ceq $testUserSid.Value + }) + }) + $ownedUserRecords = @($ownershipState.Users | Where-Object { + $_.Owned -and [string]$_.Sid -ceq $testUserSid.Value }) - foreach ($profile in $profiles) { Remove-CimInstance -InputObject $profile -ErrorAction Stop } + if ($ownedUserRecords.Count -ne 1) { + throw 'durable profile owner identity is missing' + } + $ownedProfileRecords = @($ownershipState.Profiles | Where-Object { + $_.Owned -and [string]$_.Sid -ceq $testUserSid.Value + }) + if ($profiles.Count -ne 0 -and $ownedProfileRecords.Count -eq 0) { + $currentOwnedUser = Get-LocalUser -Name $testUser -ErrorAction Stop + if ([string]$currentOwnedUser.SID.Value -cne $testUserSid.Value -or + [string]$currentOwnedUser.Description -cne + [string]$ownedUserRecords[0].OwnershipMarker) { + throw 'uncaptured profile lacks authenticated marker and SID authority' + } + foreach ($profile in $profiles) { + if ([string]$profile.SID -cne $testUserSid.Value) { + throw 'profile SID changed during ownership promotion' + } + $ownershipState.Profiles = @($ownershipState.Profiles) + @([ordered]@{ + Sid = $testUserSid.Value + LocalPath = Resolve-ValidatedOwnedProfilePath ` + ([string]$profile.LocalPath) $testUser + Owned = $true + }) + } + Write-OwnershipManifest + $ownedProfileRecords = @($ownershipState.Profiles | Where-Object { + $_.Owned -and [string]$_.Sid -ceq $testUserSid.Value + }) + } + Invoke-BoundedExternalOperation ` + 'CLEANUP' 'PROFILE_REMOVE' $recursiveOperationTimeoutMilliseconds { + foreach ($profile in $profiles) { + if ([string]$profile.SID -cne $testUserSid.Value) { + throw 'refusing to remove a profile without exact durable SID and path ownership' + } + $canonicalLocalPath = Resolve-ValidatedOwnedProfilePath ` + ([string]$profile.LocalPath) $testUser + $matchingRecords = @() + foreach ($record in $ownedProfileRecords) { + if (!$record.Owned -or [string]$record.Sid -cne $testUserSid.Value) { + continue + } + $canonicalRecordPath = Resolve-ValidatedOwnedProfilePath ` + ([string]$record.LocalPath) $testUser + if (Test-SamePath $canonicalRecordPath $canonicalLocalPath) { + $matchingRecords += $record + } + } + if ($matchingRecords.Count -ne 1) { + throw 'refusing to remove a profile without exact durable SID and path ownership' + } + # Repeat every live/durable path check at the deletion boundary. + $canonicalLocalPath = Resolve-ValidatedOwnedProfilePath ` + ([string]$profile.LocalPath) $testUser + $canonicalRecordPath = Resolve-ValidatedOwnedProfilePath ` + ([string]$matchingRecords[0].LocalPath) $testUser + if ([string]$profile.SID -cne $testUserSid.Value -or + !(Test-SamePath $canonicalRecordPath $canonicalLocalPath)) { + throw 'profile ownership changed immediately before deletion' + } + Remove-CimInstance -InputObject $profile -ErrorAction Stop + } + } } Write-CleanupSubstage 'CLEANUP' 'PROFILE' 'COMPLETE' } catch { Write-CleanupSubstage 'CLEANUP' 'PROFILE' 'FAILED' + $profileCleanupFailed = $true $cleanupFailed = $true } Write-CleanupSubstage 'CLEANUP' 'USER' 'BEGIN' try { - if (Get-LocalUser -Name $testUser -ErrorAction SilentlyContinue) { - Remove-LocalUser -Name $testUser -ErrorAction Stop + if ($profileCleanupFailed) { + throw 'profile cleanup failed; retaining authenticated local-user authority' + } + if ($testUserCreatedByRun -and $null -ne $testUserSid) { + $ownedUser = Invoke-BoundedExternalOperation ` + 'CLEANUP' 'USER_LOOKUP' $externalOperationTimeoutMilliseconds { + Get-LocalUser -Name $testUser -ErrorAction SilentlyContinue + } + if ($null -ne $ownedUser) { + if (!$ownedUser.SID.Equals($testUserSid)) { + throw 'refusing to remove a local user with a mismatched SID' + } + Invoke-BoundedExternalOperation ` + 'CLEANUP' 'USER_REMOVE' $externalOperationTimeoutMilliseconds { + Remove-LocalUser -Name $testUser -ErrorAction Stop + if (Get-LocalUser -Name $testUser -ErrorAction SilentlyContinue) { + throw 'test local user cleanup did not complete' + } + } + } } Write-CleanupSubstage 'CLEANUP' 'USER' 'COMPLETE' } catch { @@ -966,9 +2462,24 @@ try { Write-CleanupSubstage 'CLEANUP' 'INSTALL_ROOT_FALLBACK' 'BEGIN' try { - if (Test-Path -LiteralPath $installRoot) { - Remove-Item -LiteralPath $installRoot -Recurse -Force -ErrorAction Stop - } + Invoke-BoundedExternalOperation ` + 'CLEANUP' 'INSTALL_ROOT_FALLBACK' $recursiveOperationTimeoutMilliseconds { + if ($installRootCreatedByRun -and (Test-Path -LiteralPath $installRoot)) { + $ownedInstallRoot = Get-Item -LiteralPath $installRoot -Force -ErrorAction Stop + if (!$ownedInstallRoot.PSIsContainer -or + ($ownedInstallRoot.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'refusing to remove an invalid owned install tree' + } + if (!$installRootOwnedIdentity -or + (Get-DirectoryIdentity $installRoot) -cne $installRootOwnedIdentity) { + throw 'refusing to remove an install tree with a mismatched ownership identity' + } + if (@(Get-ChildItem -LiteralPath $installRoot -Force -ErrorAction Stop).Count -ne 0) { + throw 'owned install tree is not empty' + } + Remove-Item -LiteralPath $installRoot -Force -ErrorAction Stop + } + } Write-CleanupSubstage 'CLEANUP' 'INSTALL_ROOT_FALLBACK' 'COMPLETE' } catch { Write-CleanupSubstage 'CLEANUP' 'INSTALL_ROOT_FALLBACK' 'FAILED' @@ -977,36 +2488,83 @@ try { Write-CleanupSubstage 'CLEANUP' 'PROTOCOL_FALLBACK' 'BEGIN' try { - if (Test-Path -LiteralPath 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr') { - Remove-Item -LiteralPath 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr' -Recurse -Force -ErrorAction Stop - } + Invoke-BoundedExternalOperation ` + 'CLEANUP' 'PROTOCOL_FALLBACK' $externalOperationTimeoutMilliseconds { + if ($protocolCreatedByRun -and (Test-Path -LiteralPath $protocolRegistryPath)) { + if (!$protocolOwnedIdentity -or + (Get-RegistryTreeIdentity $protocolRegistryPath) -cne $protocolOwnedIdentity) { + throw 'refusing to remove protocol metadata with a mismatched ownership identity' + } + Remove-Item -LiteralPath $protocolRegistryPath -Recurse -Force -ErrorAction Stop + } + } Write-CleanupSubstage 'CLEANUP' 'PROTOCOL_FALLBACK' 'COMPLETE' } catch { Write-CleanupSubstage 'CLEANUP' 'PROTOCOL_FALLBACK' 'FAILED' $cleanupFailed = $true } - Write-CleanupSubstage 'CLEANUP' 'SHORTCUT_FALLBACK' 'BEGIN' - $shortcutFallbackFailed = $false + Write-CleanupSubstage 'CLEANUP' 'APP_PATH_FALLBACK' 'BEGIN' try { - if ($startMenuShortcutCreatedByRun -and (Test-Path -LiteralPath $startMenuShortcut)) { - Remove-Item -LiteralPath $startMenuShortcut -Force -ErrorAction Stop - } + Invoke-BoundedExternalOperation ` + 'CLEANUP' 'APP_PATH_FALLBACK' $externalOperationTimeoutMilliseconds { + if ($appPathsCreatedByRun -and (Test-Path -LiteralPath $appPathsRegistryPath)) { + if (!$appPathsOwnedIdentity -or + (Get-RegistryTreeIdentity $appPathsRegistryPath) -cne $appPathsOwnedIdentity) { + throw 'refusing to remove executable metadata with a mismatched ownership identity' + } + Remove-Item -LiteralPath $appPathsRegistryPath -Recurse -Force -ErrorAction Stop + } + } + Write-CleanupSubstage 'CLEANUP' 'APP_PATH_FALLBACK' 'COMPLETE' } catch { - $shortcutFallbackFailed = $true + Write-CleanupSubstage 'CLEANUP' 'APP_PATH_FALLBACK' 'FAILED' + $cleanupFailed = $true } + + Write-CleanupSubstage 'CLEANUP' 'HKCU_INSTALLED_FALLBACK' 'BEGIN' try { - if ($startMenuShortcutFolderCreatedByRun -and (Test-Path -LiteralPath $startMenuShortcutFolder)) { - $ownedShortcutFolder = Get-Item -LiteralPath $startMenuShortcutFolder -Force -ErrorAction Stop - if (!$ownedShortcutFolder.PSIsContainer -or - ($ownedShortcutFolder.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { - throw 'owned common Start Menu folder is invalid' + Invoke-BoundedExternalOperation ` + 'CLEANUP' 'HKCU_INSTALLED_FALLBACK' $externalOperationTimeoutMilliseconds { + Restore-HkcuInstalledBaseline } - $ownedShortcutFolderContents = @(Get-ChildItem -LiteralPath $startMenuShortcutFolder -Force -ErrorAction Stop) - if ($ownedShortcutFolderContents.Count -eq 0) { - Remove-Item -LiteralPath $startMenuShortcutFolder -Force -ErrorAction Stop + Write-CleanupSubstage 'CLEANUP' 'HKCU_INSTALLED_FALLBACK' 'COMPLETE' + } catch { + Write-CleanupSubstage 'CLEANUP' 'HKCU_INSTALLED_FALLBACK' 'FAILED' + $cleanupFailed = $true + } + + Write-CleanupSubstage 'CLEANUP' 'SHORTCUT_FALLBACK' 'BEGIN' + $shortcutFallbackFailed = $false + try { + Invoke-BoundedExternalOperation ` + 'CLEANUP' 'SHORTCUT_FALLBACK' $externalOperationTimeoutMilliseconds { + if ($startMenuShortcutCreatedByRun -and (Test-Path -LiteralPath $startMenuShortcut)) { + if (!$shortcutOwnedIdentity -or + (Get-FileIdentity $startMenuShortcut) -cne $shortcutOwnedIdentity) { + throw 'refusing to remove a shortcut with a mismatched ownership identity' + } + Remove-Item -LiteralPath $startMenuShortcut -Force -ErrorAction Stop + } + if ($startMenuShortcutFolderCreatedByRun -and + (Test-Path -LiteralPath $startMenuShortcutFolder)) { + $ownedShortcutFolder = Get-Item ` + -LiteralPath $startMenuShortcutFolder -Force -ErrorAction Stop + if (!$ownedShortcutFolder.PSIsContainer -or + ($ownedShortcutFolder.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'owned common Start Menu folder is invalid' + } + if (!$shortcutFolderOwnedIdentity -or + (Get-DirectoryIdentity $startMenuShortcutFolder) -cne $shortcutFolderOwnedIdentity) { + throw 'refusing to remove a shortcut folder with a mismatched ownership identity' + } + if (@(Get-ChildItem -LiteralPath $startMenuShortcutFolder -Force ` + -ErrorAction Stop).Count -ne 0) { + throw 'owned common Start Menu folder is not empty' + } + Remove-Item -LiteralPath $startMenuShortcutFolder -Force -ErrorAction Stop + } } - } } catch { $shortcutFallbackFailed = $true } @@ -1025,7 +2583,19 @@ try { throw 'installed Windows cleanup did not complete' } } else { + $ownershipState.State = 'EMPTY' + $ownershipState.BaselineClean = $false + $ownershipState.InstallAttempted = $false + $ownershipState.MsiTransactionState = 'NONE' + $ownershipState.Directories = @() + $ownershipState.Files = @() + $ownershipState.RegistryKeys = @() + $ownershipState.RegistryValues = @() + $ownershipState.Users = @() + $ownershipState.Profiles = @() + Write-OwnershipManifest Write-CleanupSubstage 'CLEANUP' 'FINAL_AGGREGATION' 'COMPLETE' Write-Stage 'CLEANUP' 'COMPLETE' } + } } diff --git a/apps/desktop/src/connect-discovery.test.ts b/apps/desktop/src/connect-discovery.test.ts index 72546859d..9fb0c65ea 100644 --- a/apps/desktop/src/connect-discovery.test.ts +++ b/apps/desktop/src/connect-discovery.test.ts @@ -18,6 +18,16 @@ const readyStatus = (endpoint = 'https://t-discovered123.propr.dev'): ConnectSta reasonCodes: [], }); +const deferred = () => { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +}; + describe('desktop fixed-root Connect discovery', () => { it('projects only a stable opaque profile and canonical endpoint', async () => { const service = new DesktopConnectDiscoveryService({ @@ -27,6 +37,11 @@ describe('desktop fixed-root Connect discovery', () => { discover: async () => readyStatus(), }); + const unclaimed = service.snapshotIdentityClaim( + 'propr-connect-discovered', 'https://t-discovered123.propr.dev', + ); + assert.equal(unclaimed.status, 'unclaimed'); + assert.equal(unclaimed.isCurrent(), true); const candidates = await service.discover(); assert.deepEqual(candidates, [{ id: 'propr-connect-discovered', @@ -35,6 +50,15 @@ describe('desktop fixed-root Connect discovery', () => { }]); const serialized = JSON.stringify(candidates); assert.doesNotMatch(serialized, /123e4567|root|path|environment|executable|credential|authority/i); + const claim = service.snapshotIdentityClaim( + 'propr-connect-discovered', 'https://t-discovered123.propr.dev', + ); + assert.equal(claim.status, 'claimed'); + if (claim.status === 'claimed') { + assert.equal(claim.publicInstanceIdentity, readyStatus().publicInstanceIdentity); + assert.equal(claim.isCurrent(), true); + } + assert.equal(unclaimed.isCurrent(), false); }); it('fences rediscovery to an existing managed profile and preserves its id and label', async () => { @@ -57,6 +81,40 @@ describe('desktop fixed-root Connect discovery', () => { label: saved.label, apiBaseUrl: 'https://t-recovered456.propr.dev', }); + const staleOrigin = service.snapshotIdentityClaim(saved.id, saved.apiBaseUrl); + assert.equal(staleOrigin.status, 'origin-mismatch'); + assert.equal(staleOrigin.isCurrent(), true); + const current = service.snapshotIdentityClaim(saved.id, 'https://t-recovered456.propr.dev'); + assert.equal(current.status, 'claimed'); + if (current.status === 'claimed') { + assert.equal(current.publicInstanceIdentity, readyStatus().publicInstanceIdentity); + assert.equal(current.isCurrent(), true); + } + const firstGeneration = current.status === 'claimed' ? current.generation : -1; + const releaseCommit = current.beginCommit(); + assert.ok(releaseCommit); + let rediscoverySettled = false; + const rediscovery = service.rediscover(saved.id).then(result => { + rediscoverySettled = true; + return result; + }); + await Promise.resolve(); + assert.equal(rediscoverySettled, false); + assert.equal(current.isCurrent(), false); + const pending = service.snapshotIdentityClaim(saved.id, 'https://t-recovered456.propr.dev'); + assert.equal(pending.status, 'pending'); + assert.equal(pending.isCurrent(), false); + assert.equal(pending.beginCommit(), null); + releaseCommit(); + assert.deepEqual(await rediscovery, { + id: saved.id, + label: saved.label, + apiBaseUrl: 'https://t-recovered456.propr.dev', + }); + const rotated = service.snapshotIdentityClaim(saved.id, 'https://t-recovered456.propr.dev'); + assert.equal(rotated.status, 'claimed'); + if (rotated.status === 'claimed') assert.ok(rotated.generation > firstGeneration); + assert.equal(current.isCurrent(), false); assert.equal(await service.rediscover('missing-profile'), null); }); @@ -106,4 +164,98 @@ describe('desktop fixed-root Connect discovery', () => { discover: async () => ({ ...readyStatus(), canonicalEndpoint: 'https://T-bad.propr.dev' }), }).discover(), []); }); + + it('generation-conditionally clears failed intents while keeping prior activations fenced', async () => { + const failed = deferred(); + let calls = 0; + const service = new DesktopConnectDiscoveryService({ + list: async () => ({ profiles: [], activeProfileId: null }), + }, { + supported: true, + discover: async () => calls++ === 0 ? readyStatus() : failed.promise, + }); + await service.discover(); + const active = service.snapshotIdentityClaim( + 'propr-connect-discovered', 'https://t-discovered123.propr.dev', + ); + const rejected = service.discover(); + assert.equal(active.isCurrent(), false); + assert.equal(service.snapshotIdentityClaim( + 'propr-connect-discovered', 'https://t-discovered123.propr.dev', + ).status, 'pending'); + failed.reject(new Error('native discovery failed')); + await assert.rejects(rejected, /native discovery failed/); + const recovered = service.snapshotIdentityClaim( + 'propr-connect-discovered', 'https://t-discovered123.propr.dev', + ); + assert.equal(recovered.status, 'claimed'); + assert.equal(recovered.isCurrent(), true); + assert.equal(active.isCurrent(), false); + + const invalid = new DesktopConnectDiscoveryService({ + list: async () => ({ profiles: [], activeProfileId: null }), + }, { + supported: true, + discover: async () => ({ ...readyStatus(), apiReady: false }), + }); + assert.deepEqual(await invalid.discover(), []); + const manual = invalid.snapshotIdentityClaim('manual-profile', 'https://example.test'); + assert.equal(manual.status, 'unclaimed'); + assert.equal(manual.isCurrent(), true); + + const missingOrManual = new DesktopConnectDiscoveryService({ + list: async () => ({ + profiles: [{ + id: 'manual-profile', label: 'Manual', apiBaseUrl: 'https://example.test', + createdAt: '2026-08-01T00:00:00.000Z', updatedAt: '2026-08-01T00:00:00.000Z', + }], + activeProfileId: null, + }), + }, { supported: true, discover: async () => readyStatus() }); + assert.equal(await missingOrManual.rediscover('missing-profile'), null); + assert.equal(await missingOrManual.rediscover('manual-profile'), null); + for (const profileId of ['missing-profile', 'manual-profile']) { + const claim = missingOrManual.snapshotIdentityClaim(profileId, 'https://example.test'); + assert.equal(claim.status, 'unclaimed'); + assert.equal(claim.isCurrent(), true); + } + }); + + it('scopes discovery freshness per profile and only discards stale same-profile completions', async () => { + const profile = (id: string) => ({ + id, label: id, apiBaseUrl: `https://t-${id}123.propr.dev`, + createdAt: '2026-08-01T00:00:00.000Z', updatedAt: '2026-08-01T00:00:00.000Z', + }); + const profiles = [profile('alpha'), profile('bravo')]; + const calls: Array>> = []; + const service = new DesktopConnectDiscoveryService({ + list: async () => ({ profiles, activeProfileId: null }), + }, { + supported: true, + discover: () => { + const call = deferred(); + calls.push(call); + return call.promise; + }, + }); + + const alpha = service.rediscover('alpha'); + await Promise.resolve(); + const bravo = service.rediscover('bravo'); + await Promise.resolve(); + calls[1].resolve(readyStatus('https://t-bravo456.propr.dev')); + calls[0].resolve(readyStatus('https://t-alpha456.propr.dev')); + assert.equal((await alpha)?.apiBaseUrl, 'https://t-alpha456.propr.dev'); + assert.equal((await bravo)?.apiBaseUrl, 'https://t-bravo456.propr.dev'); + + const stale = service.rediscover('alpha'); + await Promise.resolve(); + const current = service.rediscover('alpha'); + await Promise.resolve(); + calls[2].resolve(readyStatus('https://t-alpha789.propr.dev')); + assert.equal(await stale, null); + assert.equal(service.snapshotIdentityClaim('alpha', 'https://t-alpha456.propr.dev').status, 'pending'); + calls[3].resolve(readyStatus('https://t-alpha999.propr.dev')); + assert.equal((await current)?.apiBaseUrl, 'https://t-alpha999.propr.dev'); + }); }); diff --git a/apps/desktop/src/connect-discovery.ts b/apps/desktop/src/connect-discovery.ts index 73b27d248..c7f9462ae 100644 --- a/apps/desktop/src/connect-discovery.ts +++ b/apps/desktop/src/connect-discovery.ts @@ -1,4 +1,4 @@ -import { parseProprConnectEndpoint } from '@propr/shared'; +import { isPublicInstanceIdentity, parseProprConnectEndpoint } from '@propr/shared'; import type { ConnectStatusDocument } from '@propr/cli/desktop-discovery'; import type { ProfileStore } from './profile-store'; import type { DesktopDiscoveryCandidate } from './shared/contract'; @@ -7,6 +7,29 @@ const PROFILE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/; type RediscoveryProfile = Awaited['list']>>['profiles'][number]; +export type DesktopConnectIdentityClaimSnapshot = Readonly< + | { status: 'unclaimed'; isCurrent(): boolean; beginCommit(): (() => void) | null } + | { + status: 'pending'; + generation: number; + isCurrent(): false; + beginCommit(): null; + } + | { + status: 'origin-mismatch'; + generation: number; + isCurrent(): boolean; + beginCommit(): (() => void) | null; + } + | { + status: 'claimed'; + generation: number; + publicInstanceIdentity: string; + isCurrent(): boolean; + beginCommit(): (() => void) | null; + } +>; + export interface ConnectDiscoverySource { readonly supported: boolean; discover(): Promise; @@ -20,7 +43,7 @@ const candidateFromStatus = (status: ConnectStatusDocument): DesktopDiscoveryCan status.status !== 'ready' || !status.apiReady || !endpoint - || typeof status.publicInstanceIdentity !== 'string' + || !isPublicInstanceIdentity(status.publicInstanceIdentity) ) return null; return { // One fixed main-owned CLI configuration selects one native stack root. @@ -39,6 +62,17 @@ const sameRediscoveryProfile = (left: RediscoveryProfile, right: RediscoveryProf && left.updatedAt === right.updatedAt; export class DesktopConnectDiscoveryService { + readonly #identityClaims = new Map(); + #identityClaimGeneration = 0; + readonly #claimIntentGenerations = new Map(); + readonly #pendingClaimIntents = new Map(); + readonly #claimCommitLocks = new Set(); + readonly #claimCommitWaiters = new Map void>>(); + constructor( private readonly profiles: Pick, private readonly source: ConnectDiscoverySource, @@ -50,29 +84,158 @@ export class DesktopConnectDiscoveryService { async discover(): Promise { if (!this.source.supported) throw new Error('Connect discovery is unavailable'); - const candidate = candidateFromStatus(await this.source.discover()); - return candidate ? [candidate] : []; + const profileId = 'propr-connect-discovered'; + const intentGeneration = this.#beginClaimIntent(profileId); + try { + const pendingCommit = this.#waitForClaimCommit(profileId); + if (pendingCommit) await pendingCommit; + const status = await this.source.discover(); + const candidate = candidateFromStatus(status); + if (!this.#claimIntentIsCurrent(profileId, intentGeneration)) return []; + if (candidate) this.#publishIdentityClaim( + candidate.id, candidate.apiBaseUrl, status.publicInstanceIdentity!, intentGeneration, + ); + return candidate ? [candidate] : []; + } finally { + this.#finishClaimIntent(profileId, intentGeneration); + } } async rediscover(profileId: unknown): Promise { if (!this.source.supported || typeof profileId !== 'string' || !PROFILE_ID_PATTERN.test(profileId)) { throw new Error('Connect rediscovery is unavailable'); } - const current = (await this.profiles.list()).profiles.find(profile => profile.id === profileId); - const currentEndpoint = current ? parseProprConnectEndpoint(current.apiBaseUrl) : null; - if (!current || !currentEndpoint) return null; - const candidate = candidateFromStatus(await this.source.discover()); - if (!candidate) return null; - const revalidated = (await this.profiles.list()).profiles.find(profile => profile.id === profileId); - const revalidatedEndpoint = revalidated ? parseProprConnectEndpoint(revalidated.apiBaseUrl) : null; - if (!revalidated - || !revalidatedEndpoint - || revalidatedEndpoint.origin !== currentEndpoint.origin - || !sameRediscoveryProfile(current, revalidated)) return null; - return { - id: current.id, - label: current.label, - apiBaseUrl: candidate.apiBaseUrl, + const intentGeneration = this.#beginClaimIntent(profileId); + try { + const pendingCommit = this.#waitForClaimCommit(profileId); + if (pendingCommit) await pendingCommit; + const current = (await this.profiles.list()).profiles.find(profile => profile.id === profileId); + const currentEndpoint = current ? parseProprConnectEndpoint(current.apiBaseUrl) : null; + if (!current || !currentEndpoint) return null; + const status = await this.source.discover(); + const candidate = candidateFromStatus(status); + if (!candidate) return null; + const revalidated = (await this.profiles.list()).profiles.find(profile => profile.id === profileId); + const revalidatedEndpoint = revalidated ? parseProprConnectEndpoint(revalidated.apiBaseUrl) : null; + if (!revalidated + || !revalidatedEndpoint + || revalidatedEndpoint.origin !== currentEndpoint.origin + || !sameRediscoveryProfile(current, revalidated) + || !this.#claimIntentIsCurrent(profileId, intentGeneration)) return null; + this.#publishIdentityClaim( + current.id, candidate.apiBaseUrl, status.publicInstanceIdentity!, intentGeneration, + ); + return { + id: current.id, + label: current.label, + apiBaseUrl: candidate.apiBaseUrl, + }; + } finally { + this.#finishClaimIntent(profileId, intentGeneration); + } + } + + snapshotIdentityClaim(profileId: string, origin: string): DesktopConnectIdentityClaimSnapshot { + const claim = this.#identityClaims.get(profileId); + const intentGeneration = this.#claimIntentGeneration(profileId); + const isCurrent = () => this.#identityClaims.get(profileId) === claim + && this.#claimIntentGeneration(profileId) === intentGeneration + && !this.#pendingClaimIntents.has(profileId); + const beginCommit = () => this.#beginClaimCommit(profileId, isCurrent); + const pendingIntent = this.#pendingClaimIntents.get(profileId); + if (pendingIntent !== undefined) { + return Object.freeze({ + status: 'pending' as const, + generation: pendingIntent, + isCurrent: () => false as const, + beginCommit: () => null, + }); + } + if (!claim) { + return Object.freeze({ + status: 'unclaimed' as const, + isCurrent, + beginCommit, + }); + } + if (claim.origin !== origin) { + return Object.freeze({ + status: 'origin-mismatch' as const, + generation: claim.generation, + isCurrent, + beginCommit, + }); + } + return Object.freeze({ + status: 'claimed' as const, + generation: claim.generation, + publicInstanceIdentity: claim.publicInstanceIdentity, + isCurrent, + beginCommit, + }); + } + + #claimIntentGeneration(profileId: string): number { + return this.#claimIntentGenerations.get(profileId) ?? 0; + } + + #beginClaimIntent(profileId: string): number { + const generation = this.#claimIntentGeneration(profileId) + 1; + this.#claimIntentGenerations.set(profileId, generation); + // Publish pending synchronously before the first await. Existing active + // snapshots become stale immediately, and no later pairing can acquire the + // commit gate while native discovery is unresolved. + this.#pendingClaimIntents.set(profileId, generation); + return generation; + } + + #claimIntentIsCurrent(profileId: string, generation: number): boolean { + return this.#claimIntentGeneration(profileId) === generation + && this.#pendingClaimIntents.get(profileId) === generation; + } + + #finishClaimIntent(profileId: string, generation: number): void { + if (this.#pendingClaimIntents.get(profileId) === generation) { + this.#pendingClaimIntents.delete(profileId); + } + } + + #waitForClaimCommit(profileId: string): Promise | null { + if (!this.#claimCommitLocks.has(profileId)) return null; + return new Promise(resolve => { + const waiters = this.#claimCommitWaiters.get(profileId) ?? []; + waiters.push(resolve); + this.#claimCommitWaiters.set(profileId, waiters); + }); + } + + #beginClaimCommit(profileId: string, isCurrent: () => boolean): (() => void) | null { + if (!isCurrent() || this.#claimCommitLocks.has(profileId)) return null; + this.#claimCommitLocks.add(profileId); + let released = false; + return () => { + if (released) return; + released = true; + this.#claimCommitLocks.delete(profileId); + const waiters = this.#claimCommitWaiters.get(profileId) ?? []; + this.#claimCommitWaiters.delete(profileId); + waiters.forEach(resolve => resolve()); }; } + + #publishIdentityClaim( + profileId: string, + origin: string, + publicInstanceIdentity: string, + intentGeneration: number, + ): void { + if (!this.#claimIntentIsCurrent(profileId, intentGeneration) + || this.#claimCommitLocks.has(profileId)) return; + this.#identityClaims.set(profileId, { + origin, + publicInstanceIdentity, + generation: ++this.#identityClaimGeneration, + }); + this.#pendingClaimIntents.delete(profileId); + } } diff --git a/apps/desktop/src/credential-service.pairing-browser.test.ts b/apps/desktop/src/credential-service.pairing-browser.test.ts index 7ca3ccfb7..e732d32cc 100644 --- a/apps/desktop/src/credential-service.pairing-browser.test.ts +++ b/apps/desktop/src/credential-service.pairing-browser.test.ts @@ -113,7 +113,7 @@ afterEach(async () => { }); describe('DesktopCredentialService pairing browser sink', () => { - it('pairs a manually entered remote through browser approval, persistence, probe, and activation end to end', async () => { + it('pairs through the browser journey and rejects a response URL replacement', async () => { const opened: string[] = []; const requests: Array<{ url: string; authorization: string | null }> = []; let browserApproved = false; @@ -142,6 +142,7 @@ describe('DesktopCredentialService pairing browser sink', () => { assert.deepEqual(paired, { paired: true }); assert.deepEqual(opened, [approvalUrl]); assert.deepEqual(requests.map(request => request.url), [ + `${origin}/api/desktop/discovery`, `${origin}/api/desktop/discovery`, `${origin}/api/desktop/pairings`, `${origin}/api/desktop/pairings/${pairingId}/poll`, @@ -150,26 +151,24 @@ describe('DesktopCredentialService pairing browser sink', () => { `${origin}/api/auth/user`, ]); assert.deepEqual(requests.map(request => request.authorization), [ - null, null, null, null, null, `Bearer ${instanceToken}`, + null, null, null, null, null, null, `Bearer ${instanceToken}`, ]); assert.deepEqual(service.prepareRequest( `${origin}/api/tasks`, { [DESKTOP_TRANSPORT_SCOPE_HEADER]: activated.transportScope }, ).requestHeaders, { Authorization: `Bearer ${instanceToken}` }); assert.equal(JSON.stringify([initialProbe, paired, probed, activated, opened]).includes(instanceToken), false); - }); - it('rejects a URL replaced after the credential service receives the API response', async () => { - const opened: string[] = []; - const service = await createService(request => openApprovedDesktopPairingUrl({ + const replacedOpened: string[] = []; + const replacedService = await createService(request => openApprovedDesktopPairingUrl({ ...request, approvalUrl: `${origin}/api/desktop/pairings/dpr_${'B'.repeat(22)}/browser`, - }, { openExternal: async url => { opened.push(url); } })); + }, { openExternal: async url => { replacedOpened.push(url); } })); await assert.rejects( - service.pair({ id: 'profile-a', label: 'A', apiBaseUrl: origin }), + replacedService.pair({ id: 'profile-a', label: 'A', apiBaseUrl: origin }), /Desktop pairing browser request was rejected/, ); - assert.deepEqual(opened, []); + assert.deepEqual(replacedOpened, []); }); }); diff --git a/apps/desktop/src/credential-service.test.ts b/apps/desktop/src/credential-service.test.ts index 3088d524b..1328ac207 100644 --- a/apps/desktop/src/credential-service.test.ts +++ b/apps/desktop/src/credential-service.test.ts @@ -13,7 +13,9 @@ import { PROPR_API_COMPATIBILITY, PROPR_UI_COMPATIBILITY, } from '@propr/shared'; +import type { ConnectStatusDocument } from '@propr/cli/desktop-discovery'; import { DesktopCredentialService } from './credential-service'; +import { DesktopConnectDiscoveryService } from './connect-discovery'; import { ProfileStore, type EncryptionProvider, type StoredCredential } from './profile-store'; const temporaryDirectories: string[] = []; @@ -90,11 +92,29 @@ const discovery = { }; const token = (character: string) => `propr_it_${character.repeat(43)}`; const credential = (profileId: string, origin: string, character: string): StoredCredential => ({ - version: 1, + version: 2, profileId, origin, + publicInstanceIdentity: discovery.publicInstanceIdentity, token: token(character), }); +const connectStatus = ( + endpoint: string, + publicInstanceIdentity: string, +): ConnectStatusDocument => ({ + schemaVersion: 1, + status: 'ready', + canonicalEndpoint: endpoint, + publicInstanceIdentity, + configured: true, + enabled: true, + sidecarRunning: true, + apiReady: true, + restartRequired: false, + compatibility: '2026-08-01', + version: '0.8.15', + reasonCodes: [], +}); const deferred = () => { let resolve!: (value: T) => void; const promise = new Promise(settle => { resolve = settle; }); @@ -114,7 +134,23 @@ const createStore = async (): Promise => { const createCredentialService = ( dependencies: ConstructorParameters[0], ): DesktopCredentialService => { - const service = new DesktopCredentialService(dependencies); + const suppliedFetch = dependencies.fetch; + const service = new DesktopCredentialService({ + ...dependencies, + fetch: async (input, init) => { + if (!input.toString().endsWith('/api/desktop/discovery')) return suppliedFetch(input, init); + try { + const response = await suppliedFetch(input, init); + if (response.status === 200 + && response.headers.get('content-type')?.includes('application/json')) return response; + } catch (error) { + if (init?.signal?.aborted) throw error; + // Legacy fixtures below model only the post-discovery operation. They + // still cross the real strict parser using this complete document. + } + return json(discovery); + }, + }); credentialServices.push(service); return service; }; @@ -125,12 +161,66 @@ afterEach(async () => { }); describe('main-process desktop credential service', () => { - it('classifies a pre-desktop discovery 401 as incompatible without attempting authentication', async () => { + it('fails a relaunched same-origin replacement closed before sending the stored bearer', async () => { const store = await createStore(); + const profile = await store.save({ id: 'profile-replaced', label: 'A', apiBaseUrl: 'https://a.example.test' }); + await store.writeCredential(credential(profile.id, profile.apiBaseUrl, 'A')); const requests: Array<{ url: string; authorization: string | null }> = []; - const service = createCredentialService({ + const replacementDiscovery = { + ...discovery, + publicInstanceIdentity: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + }; + const service = new DesktopCredentialService({ profiles: store, - clientName: 'Test desktop', + clientName: 'Relaunch identity test', + openPairingBrowser: async () => undefined, + fetch: async (input, init) => { + requests.push({ + url: input.toString(), + authorization: new Headers(init?.headers).get('Authorization'), + }); + return json(replacementDiscovery); + }, + }); + credentialServices.push(service); + + const result = await service.probe({ id: profile.id, label: profile.label, apiBaseUrl: profile.apiBaseUrl }); + + assert.equal(result.status, 'authentication-required'); + assert.ok(requests.length >= 1); + assert.equal(requests[0].url, `${profile.apiBaseUrl}/api/desktop/discovery`); + assert.equal(requests.some(request => request.authorization !== null), false); + assert.equal(await store.readCredential(profile.id), null); + }); + + it('fails malformed identity closed and classifies legacy public-discovery 401 safely', async () => { + const store = await createStore(); + const profile = await store.save({ id: 'profile-malformed', label: 'A', apiBaseUrl: 'https://a.example.test' }); + await store.writeCredential(credential(profile.id, profile.apiBaseUrl, 'A')); + const authorizations: Array = []; + const service = new DesktopCredentialService({ + profiles: store, + clientName: 'Malformed relaunch test', + openPairingBrowser: async () => undefined, + fetch: async (_input, init) => { + authorizations.push(new Headers(init?.headers).get('Authorization')); + const { publicInstanceIdentity: _missing, ...malformed } = discovery; + return json(malformed); + }, + }); + credentialServices.push(service); + + const result = await service.probe({ id: profile.id, label: profile.label, apiBaseUrl: profile.apiBaseUrl }); + + assert.equal(result.status, 'authentication-required'); + assert.equal(authorizations.some(Boolean), false); + assert.equal(await store.readCredential(profile.id), null); + + const legacyStore = await createStore(); + const requests: Array<{ url: string; authorization: string | null }> = []; + const legacyService = new DesktopCredentialService({ + profiles: legacyStore, + clientName: 'Legacy remote test', openPairingBrowser: async () => undefined, fetch: async (input, init) => { requests.push({ @@ -143,14 +233,15 @@ describe('main-process desktop credential service', () => { }, 401); }, }); + credentialServices.push(legacyService); - const result = await service.probe({ + const legacyResult = await legacyService.probe({ id: 'legacy-remote', label: 'Legacy remote', apiBaseUrl: 'https://legacy.example.test', }); - assert.deepEqual(result, { + assert.deepEqual(legacyResult, { status: 'incompatible', message: 'This instance requires authentication for public desktop discovery. Check its proxy configuration or update ProPR, then try again.', }); @@ -158,7 +249,331 @@ describe('main-process desktop credential service', () => { url: 'https://legacy.example.test/api/desktop/discovery', authorization: null, }]); - assert.doesNotMatch(JSON.stringify(result), /private legacy authentication detail|AUTHENTICATION_REQUIRED/); + assert.doesNotMatch(JSON.stringify(legacyResult), /private legacy authentication detail|AUTHENTICATION_REQUIRED/); + }); + + it('revalidates an old Socket.IO reconnect and sends zero bearer requests after identity rotation', async () => { + const store = await createStore(); + const profile = await store.save({ id: 'profile-socket-rotation', label: 'A', apiBaseUrl: 'https://a.example.test' }); + await store.writeCredential(credential(profile.id, profile.apiBaseUrl, 'A')); + let rotated = false; + const requests: Array<{ url: string; authorization: string | null }> = []; + const service = new DesktopCredentialService({ + profiles: store, + clientName: 'Socket rotation test', + openPairingBrowser: async () => undefined, + fetch: async (input, init) => { + const url = input.toString(); + const authorization = new Headers(init?.headers).get('Authorization'); + requests.push({ url, authorization }); + if (url.endsWith('/api/desktop/discovery')) return json(rotated + ? { ...discovery, publicInstanceIdentity: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' } + : discovery); + return json({ username: 'octocat' }); + }, + }); + credentialServices.push(service); + const ready = await service.probe({ id: profile.id, label: profile.label, apiBaseUrl: profile.apiBaseUrl }); + assert.equal(ready.status, 'ready'); + if (ready.status !== 'ready') return; + const active = await service.activate(ready.activationTicket); + rotated = true; + const beforeReconnect = requests.length; + const result = await service.prepareRequestAsync( + `wss://a.example.test/socket.io/?transport=websocket&proprDesktopTransportScope=${active.transportScope}`, + {}, { resourceType: 'webSocket' }, + ); + + assert.deepEqual(result, { cancel: true }); + assert.equal(requests[beforeReconnect].url, `${profile.apiBaseUrl}/api/desktop/discovery`); + assert.equal(requests[beforeReconnect].authorization, null); + assert.equal(requests.slice(beforeReconnect).some(request => request.authorization !== null), false); + assert.equal(await store.readCredential(profile.id), null); + assert.deepEqual(service.prepareRequest( + `${profile.apiBaseUrl}/api/tasks`, transportHeaders(active.transportScope), + ), { cancel: true }); + }); + + it('fences old and concurrently rotated Connect claims through pairing, commit, and transport activation', async () => { + const store = await createStore(); + const origins = { + old: 'https://t-old123.propr.dev', + current: 'https://t-current456.propr.dev', + replacement: 'https://t-replacement789.propr.dev', + } as const; + const identities = { + old: discovery.publicInstanceIdentity, + current: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + replacement: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + } as const; + const profile = await store.save({ + id: 'connect-saved', label: 'Saved Connect', apiBaseUrl: origins.old, + }); + const oldCredential: StoredCredential = { + ...credential(profile.id, origins.old, 'A'), + publicInstanceIdentity: identities.old, + }; + await store.writeCredential(oldCredential); + await store.setActive(profile.id); + + let nativeStatus = connectStatus(origins.old, identities.old); + const connect = new DesktopConnectDiscoveryService(store, { + supported: true, + discover: async () => nativeStatus, + }); + assert.deepEqual(await connect.rediscover(profile.id), { + id: profile.id, label: profile.label, apiBaseUrl: origins.old, + }); + const oldClaim = connect.snapshotIdentityClaim(profile.id, origins.old); + assert.equal(oldClaim.status, 'claimed'); + + const pairingNow = Date.parse('2026-01-01T00:00:00.000Z'); + const stalePollStarted = deferred(); + const releaseStalePoll = deferred(); + const requests: Array<{ + url: string; + authorization: string | null; + transportScope: string | null; + body: string | null; + }> = []; + let pairingNumber = 0; + const service = createCredentialService({ + profiles: store, + clientName: 'Connect claim test', + pairingTiming: { now: () => pairingNow, sleep: async () => undefined }, + openPairingBrowser: async () => undefined, + snapshotConnectIdentityClaim: (profileId, origin) => connect.snapshotIdentityClaim(profileId, origin), + fetch: async (input, init) => { + const url = input.toString(); + const headers = new Headers(init?.headers); + requests.push({ + url, + authorization: headers.get('Authorization'), + transportScope: headers.get('X-ProPR-Desktop-Transport-Scope'), + body: typeof init?.body === 'string' ? init.body : null, + }); + const origin = new URL(url).origin; + const identity = origin === origins.old + ? identities.old + : origin === origins.current ? identities.current : identities.replacement; + if (url.endsWith('/api/desktop/discovery')) { + return json({ ...discovery, publicInstanceIdentity: identity }); + } + if (url.endsWith('/api/auth/user')) return json({ username: 'connect-user' }); + if (url.endsWith('/api/desktop/pairings')) { + pairingNumber += 1; + const pairingCharacter = pairingNumber === 1 ? 'B' : pairingNumber === 2 ? 'C' : 'D'; + return pairingStartResponse(url, init, { + pairingId: `dpr_${pairingCharacter.repeat(22)}`, + deviceSecret: pairingCharacter.repeat(43), + approvalUrl: `${origin}/approve`, + expiresAt: new Date(pairingNow + 10_000).toISOString(), + interval: 1, + }, 201); + } + if (url.includes(`/dpr_${'B'.repeat(22)}/poll`)) { + return provisionalPairingResponse(url, token('B')); + } + if (url.includes(`/dpr_${'C'.repeat(22)}/poll`)) { + stalePollStarted.resolve(); + return releaseStalePoll.promise; + } + if (url.includes(`/dpr_${'D'.repeat(22)}/poll`)) { + return provisionalPairingResponse(url, token('D')); + } + if (url.includes('/activate')) return pairingActivationReceipt(); + if (url.includes(`/dpr_${'C'.repeat(22)}/cancel`)) { + return json({ status: 'cancelled', cancelledAt: '2026-01-01T00:00:02.000Z' }); + } + if (url.endsWith('/api/desktop/tokens/current')) { + const committed = await store.readCredential(profile.id); + if (origin === origins.old) { + assert.equal(committed?.origin, origins.current); + assert.equal(committed?.token, token('B')); + assert.equal(headers.get('Authorization'), `Bearer ${oldCredential.token}`); + } else { + assert.equal(origin, origins.current); + assert.equal(committed?.origin, origins.replacement); + assert.equal(committed?.token, token('D')); + assert.equal(headers.get('Authorization'), `Bearer ${token('B')}`); + } + return new Response(null, { status: 204 }); + } + throw new Error(`Unexpected request: ${url}`); + }, + }); + + const oldReady = await service.probe({ + id: profile.id, label: profile.label, apiBaseUrl: origins.old, + }); + assert.equal(oldReady.status, 'ready'); + if (oldReady.status !== 'ready') return; + const oldActivation = await service.activate(oldReady.activationTicket); + + nativeStatus = connectStatus(origins.current, identities.current); + assert.deepEqual(await connect.rediscover(profile.id), { + id: profile.id, label: profile.label, apiBaseUrl: origins.current, + }); + const currentClaim = connect.snapshotIdentityClaim(profile.id, origins.current); + assert.equal(currentClaim.status, 'claimed'); + assert.equal(oldClaim.isCurrent(), false); + if (oldClaim.status === 'claimed' && currentClaim.status === 'claimed') { + assert.ok(currentClaim.generation > oldClaim.generation); + } + + const beforeDetachedTransport = requests.length; + assert.deepEqual(service.prepareRequest( + `${origins.old}/api/tasks`, transportHeaders(oldActivation.transportScope), + ), { cancel: true }); + assert.deepEqual(await service.prepareRequestAsync( + `wss://${new URL(origins.old).host}/socket.io/?transport=websocket&proprDesktopTransportScope=${oldActivation.transportScope}`, + {}, { resourceType: 'webSocket' }, + ), { cancel: true }); + assert.equal(requests.slice(beforeDetachedTransport) + .some(request => request.authorization !== null), false); + + const beforeStaleOrigin = requests.length; + await assert.rejects(service.pair({ + id: profile.id, label: profile.label, apiBaseUrl: origins.old, + }), /Connect origin changed/i); + assert.equal(requests.length, beforeStaleOrigin); + assert.deepEqual(await store.readCredential(profile.id), oldCredential); + + const currentPairingStart = requests.length; + await service.pair({ id: profile.id, label: 'Current Connect', apiBaseUrl: origins.current }); + await service.awaitIdle(); + const currentBinding = testPairingBindings.get(origins.current); + assert.match(String(currentBinding?.credentialGeneration), /^[A-Za-z0-9_-]{22}$/); + assert.deepEqual(await store.readCredential(profile.id), { + version: 2, + profileId: profile.id, + origin: origins.current, + publicInstanceIdentity: identities.current, + token: token('B'), + }); + const currentIdentityMatch = requests.findIndex((request, index) => index >= currentPairingStart + && request.url === `${origins.current}/api/desktop/discovery`); + const oldRevocation = requests.findIndex(request => request.url === `${origins.old}/api/desktop/tokens/current` + && request.authorization === `Bearer ${oldCredential.token}`); + assert.ok(currentIdentityMatch >= currentPairingStart); + assert.ok(oldRevocation > currentIdentityMatch); + assert.equal(requests.slice(currentPairingStart, currentIdentityMatch + 1) + .some(request => request.authorization !== null), false); + assert.equal(requests.slice(currentPairingStart) + .some(request => request.authorization === `Bearer ${oldCredential.token}` + && !request.url.endsWith('/api/desktop/tokens/current')), false); + + const currentReady = await service.probe({ + id: profile.id, label: 'Current Connect', apiBaseUrl: origins.current, + }); + assert.equal(currentReady.status, 'ready'); + if (currentReady.status !== 'ready') return; + const currentActivation = await service.activate(currentReady.activationTicket); + assert.equal(currentActivation.identityEpoch, currentBinding?.credentialGeneration); + assert.notEqual(currentActivation.identityEpoch, oldActivation.identityEpoch); + assert.notEqual(currentActivation.transportScope, oldActivation.transportScope); + assert.deepEqual(service.prepareRequest( + `${origins.old}/api/tasks`, transportHeaders(oldActivation.transportScope), + ), { cancel: true }); + assert.deepEqual(service.prepareRequest( + `wss://${new URL(origins.old).host}/socket.io/?transport=websocket&proprDesktopTransportScope=${oldActivation.transportScope}`, + {}, { resourceType: 'webSocket' }, + ), { cancel: true }); + assert.deepEqual((await service.prepareRequestAsync( + `${origins.current}/api/tasks`, transportHeaders(currentActivation.transportScope), + )).requestHeaders, { Authorization: `Bearer ${token('B')}` }); + assert.deepEqual((await service.prepareRequestAsync( + `wss://${new URL(origins.current).host}/socket.io/?transport=websocket&proprDesktopTransportScope=${currentActivation.transportScope}`, + {}, { resourceType: 'webSocket' }, + )).requestHeaders, { Authorization: `Bearer ${token('B')}` }); + + const concurrentPairingStart = requests.length; + const stalePairing = service.pair({ + id: profile.id, label: 'Stale current Connect', apiBaseUrl: origins.current, + }); + await stalePollStarted.promise; + nativeStatus = connectStatus(origins.replacement, identities.replacement); + assert.deepEqual(await connect.rediscover(profile.id), { + id: profile.id, label: 'Current Connect', apiBaseUrl: origins.replacement, + }); + const replacementClaim = connect.snapshotIdentityClaim(profile.id, origins.replacement); + assert.equal(replacementClaim.status, 'claimed'); + assert.equal(currentClaim.isCurrent(), false); + if (currentClaim.status === 'claimed' && replacementClaim.status === 'claimed') { + assert.ok(replacementClaim.generation > currentClaim.generation); + } + releaseStalePoll.resolve(provisionalPairingResponse( + `${origins.current}/api/desktop/pairings/dpr_${'C'.repeat(22)}/poll`, token('C'), + )); + await assert.rejects(stalePairing, /cancelled/i); + await service.awaitIdle(); + const concurrentRequests = requests.slice(concurrentPairingStart); + assert.equal(concurrentRequests.some(request => request.url.includes('/activate')), false); + assert.equal(concurrentRequests.filter(request => request.url.includes(`/dpr_${'C'.repeat(22)}/cancel`)).length, 1); + assert.equal(concurrentRequests.some(request => request.authorization !== null), false); + assert.equal(concurrentRequests.some(request => request.body?.includes(token('B')) + || request.body?.includes(token('C'))), false); + assert.deepEqual(await store.readCredential(profile.id), { + version: 2, + profileId: profile.id, + origin: origins.current, + publicInstanceIdentity: identities.current, + token: token('B'), + }); + assert.deepEqual(await store.pendingRevocations(), []); + + const replacementPairingStart = requests.length; + await service.pair({ + id: profile.id, label: 'Replacement Connect', apiBaseUrl: origins.replacement, + }); + await service.awaitIdle(); + const replacementBinding = testPairingBindings.get(origins.replacement); + assert.match(String(replacementBinding?.credentialGeneration), /^[A-Za-z0-9_-]{22}$/); + assert.notEqual(replacementBinding?.credentialGeneration, currentBinding?.credentialGeneration); + const replacementIdentityMatch = requests.findIndex((request, index) => index >= replacementPairingStart + && request.url === `${origins.replacement}/api/desktop/discovery`); + const currentRevocation = requests.findIndex((request, index) => index >= replacementPairingStart + && request.url === `${origins.current}/api/desktop/tokens/current` + && request.authorization === `Bearer ${token('B')}`); + assert.ok(replacementIdentityMatch >= replacementPairingStart); + assert.ok(currentRevocation > replacementIdentityMatch); + assert.equal(requests.slice(concurrentPairingStart, replacementIdentityMatch + 1) + .some(request => request.authorization !== null), false); + assert.equal(requests.some(request => request.authorization === `Bearer ${token('C')}`), false); + assert.deepEqual(await store.readCredential(profile.id), { + version: 2, + profileId: profile.id, + origin: origins.replacement, + publicInstanceIdentity: identities.replacement, + token: token('D'), + }); + + const replacementReady = await service.probe({ + id: profile.id, label: 'Replacement Connect', apiBaseUrl: origins.replacement, + }); + assert.equal(replacementReady.status, 'ready'); + if (replacementReady.status !== 'ready') return; + const replacementActivation = await service.activate(replacementReady.activationTicket); + assert.equal(replacementActivation.identityEpoch, replacementBinding?.credentialGeneration); + assert.notEqual(replacementActivation.transportScope, currentActivation.transportScope); + assert.deepEqual(service.prepareRequest( + `${origins.current}/api/tasks`, transportHeaders(currentActivation.transportScope), + ), { cancel: true }); + assert.deepEqual(service.prepareRequest( + `wss://${new URL(origins.current).host}/socket.io/?transport=websocket&proprDesktopTransportScope=${currentActivation.transportScope}`, + {}, { resourceType: 'webSocket' }, + ), { cancel: true }); + assert.deepEqual((await service.prepareRequestAsync( + `${origins.replacement}/api/tasks`, transportHeaders(replacementActivation.transportScope), + )).requestHeaders, { Authorization: `Bearer ${token('D')}` }); + assert.deepEqual((await service.prepareRequestAsync( + `wss://${new URL(origins.replacement).host}/socket.io/?transport=websocket&proprDesktopTransportScope=${replacementActivation.transportScope}`, + {}, { resourceType: 'webSocket' }, + )).requestHeaders, { Authorization: `Bearer ${token('D')}` }); + assert.equal(requests.some(request => request.url.includes(oldActivation.transportScope) + || request.url.includes(currentActivation.transportScope) + || request.transportScope === oldActivation.transportScope + || request.transportScope === currentActivation.transportScope), false); }); it('injects the active bearer only for its bound profile origin and strips renderer identity', async () => { @@ -191,9 +606,9 @@ describe('main-process desktop credential service', () => { assert.match(result.activationTicket, /^[A-Za-z0-9_-]{43}$/); assert.equal('transportScope' in result, false); const activated = await service.activate(result.activationTicket); - assert.deepEqual(service.prepareRequest('https://a.example.test/api/tasks', transportHeaders(activated.transportScope, { + assert.deepEqual((await service.prepareRequestAsync('https://a.example.test/api/tasks', transportHeaders(activated.transportScope, { Cookie: 'legacy=session', Authorization: 'Bearer renderer-controlled', Accept: 'application/json', - })).requestHeaders, { + }))).requestHeaders, { Accept: 'application/json', Authorization: `Bearer ${token('A')}`, }); @@ -203,14 +618,14 @@ describe('main-process desktop credential service', () => { assert.deepEqual(service.prepareRequest('https://a.example.test/assets/app.js', transportHeaders(activated.transportScope, { Cookie: 'active=session', Authorization: 'Bearer renderer-controlled', })), { cancel: true }); - assert.deepEqual(service.prepareRequest(`wss://a.example.test/socket.io/?transport=websocket&proprDesktopTransportScope=${activated.transportScope}`, { + assert.deepEqual((await service.prepareRequestAsync(`wss://a.example.test/socket.io/?transport=websocket&proprDesktopTransportScope=${activated.transportScope}`, { Cookie: 'socket=session', Authorization: 'Bearer renderer-controlled', - }, { resourceType: 'webSocket' }).requestHeaders, { Authorization: `Bearer ${token('A')}` }); - assert.deepEqual(service.prepareRequest('https://a.example.test/api/tasks', transportHeaders(activated.transportScope, { + }, { resourceType: 'webSocket' })).requestHeaders, { Authorization: `Bearer ${token('A')}` }); + assert.deepEqual((await service.prepareRequestAsync('https://a.example.test/api/tasks', transportHeaders(activated.transportScope, { Cookie: 'legacy=session', Authorization: 'Bearer renderer-controlled', 'X-ProPR-Desktop-Main-Request': 'renderer-forgery', - })).requestHeaders, { Authorization: `Bearer ${token('A')}` }); + }))).requestHeaders, { Authorization: `Bearer ${token('A')}` }); assert.deepEqual(service.prepareRequest('https://a.example.test/api/desktop/pairings', {}), { cancel: true, }); @@ -220,7 +635,7 @@ describe('main-process desktop credential service', () => { assert.deepEqual(service.prepareRequest('http://remote.example.test/api/tasks', {}), { cancel: true }); assert.deepEqual(service.prepareRequest('http://127.1:3000/api/tasks', {}), { cancel: true }); assert.deepEqual(service.prepareRequest('http://local%68ost:3000/api/tasks', {}), { cancel: true }); - assert.deepEqual(wireRequests.at(-1), { + assert.deepEqual(wireRequests.find(request => request.url.endsWith('/api/auth/user')), { url: 'https://a.example.test/api/auth/user', headers: { authorization: `Bearer ${token('A')}` }, }); @@ -269,12 +684,12 @@ describe('main-process desktop credential service', () => { if (readyB.status !== 'ready') return; const activatedB = await service.activate(readyB.activationTicket); - assert.deepEqual(service.prepareRequest('https://same.example.test/api/tasks', transportHeaders(activatedB.transportScope, { + assert.deepEqual((await service.prepareRequestAsync('https://same.example.test/api/tasks', transportHeaders(activatedB.transportScope, { Cookie: 'profile-a=session', Authorization: `Bearer ${token('A')}`, - })).requestHeaders, { Authorization: `Bearer ${token('B')}` }); + }))).requestHeaders, { Authorization: `Bearer ${token('B')}` }); }); - it('detaches profile B credential A without sending any bearer request to A or minting a ticket', async () => { + it('detaches origin and identity mismatches before bearer use or early protocol exits', async () => { const store = await createStore(); const profileB = await store.save({ id: 'profile-b', label: 'B', apiBaseUrl: 'https://b.example.test' }); await store.writeCredential(credential(profileB.id, 'https://a.example.test', 'A')); @@ -303,6 +718,52 @@ describe('main-process desktop credential service', () => { assert.equal(requests.some(request => request.url.startsWith('https://a.example.test/')), false); assert.equal(await store.readCredential(profileB.id), null); assert.equal((await store.list()).activeProfileId, null); + + const replacementIdentity = '123e4567-e89b-42d3-a456-426614174001'; + for (const [name, replacementDiscovery, expectedStatus] of [ + ['incompatible', { + ...discovery, + version: '99.0.0', + apiCompatibility: '9999-12-31', + publicInstanceIdentity: replacementIdentity, + }, 'incompatible'], + ['capability', { + ...discovery, + publicInstanceIdentity: replacementIdentity, + desktopAuthentication: { + ...discovery.desktopAuthentication, + socketIoBearerAuthentication: false, + }, + }, 'authentication-required'], + ] as const) { + const store = await createStore(); + const profile = await store.save({ + id: `identity-${name}`, label: name, apiBaseUrl: `https://${name}.example.test`, + }); + await store.writeCredential(credential(profile.id, profile.apiBaseUrl, 'A')); + const requests: Array<{ url: string; authorization: string | null }> = []; + const service = createCredentialService({ + profiles: store, + clientName: 'Identity early-exit test', + openPairingBrowser: async () => undefined, + fetch: async (input, init) => { + requests.push({ + url: input.toString(), + authorization: new Headers(init?.headers).get('Authorization'), + }); + return json(replacementDiscovery); + }, + }); + + const result = await service.probe({ + id: profile.id, label: profile.label, apiBaseUrl: profile.apiBaseUrl, + }); + assert.equal(result.status, expectedStatus); + assert.equal(await store.readCredential(profile.id), null); + assert.ok(requests.length >= 1); + assert.equal(requests.every(request => request.url === `${profile.apiBaseUrl}/api/desktop/discovery` + && request.authorization === null), true); + } }); it('does not mint a ticket when a delayed B probe observes credential replacement with origin A', async () => { @@ -431,10 +892,10 @@ describe('main-process desktop credential service', () => { assert.equal(staleA.status, 'offline'); assert.match(staleA.message, /connection changed/i); - assert.deepEqual(service.prepareRequest( + assert.deepEqual((await service.prepareRequestAsync( 'https://same.example.test/api/tasks', transportHeaders(activatedB.transportScope), - ).requestHeaders, { Authorization: `Bearer ${token('B')}` }); + )).requestHeaders, { Authorization: `Bearer ${token('B')}` }); }); it('keeps A active while B is only probed and if B selection persistence fails', async () => { @@ -467,17 +928,17 @@ describe('main-process desktop credential service', () => { if (probeB.status !== 'ready') return; assert.equal((await store.list()).activeProfileId, profileA.id); - assert.deepEqual(service.prepareRequest( + assert.deepEqual((await service.prepareRequestAsync( profileA.apiBaseUrl + '/api/tasks', transportHeaders(activeA.transportScope), - ).requestHeaders, { Authorization: `Bearer ${token('A')}` }); + )).requestHeaders, { Authorization: `Bearer ${token('A')}` }); failActivationState = true; await assert.rejects(service.activate(probeB.activationTicket)); failActivationState = false; assert.notEqual((await store.list()).activeProfileId, profileB.id); - assert.deepEqual(service.prepareRequest( + assert.deepEqual((await service.prepareRequestAsync( profileA.apiBaseUrl + '/api/tasks', transportHeaders(activeA.transportScope), - ).requestHeaders, { Authorization: `Bearer ${token('A')}` }); + )).requestHeaders, { Authorization: `Bearer ${token('A')}` }); }); it('keeps B active during a direct same-origin A probe and rejects replayed activation tickets', async () => { @@ -503,9 +964,9 @@ describe('main-process desktop credential service', () => { const probeA = await service.probe({ id: profileA.id, label: profileA.label, apiBaseUrl: profileA.apiBaseUrl }); assert.equal(probeA.status, 'ready'); assert.equal((await store.list()).activeProfileId, profileB.id); - assert.deepEqual(service.prepareRequest( + assert.deepEqual((await service.prepareRequestAsync( profileB.apiBaseUrl + '/api/tasks', transportHeaders(activeB.transportScope), - ).requestHeaders, { Authorization: `Bearer ${token('B')}` }); + )).requestHeaders, { Authorization: `Bearer ${token('B')}` }); }); it('rejects activation after candidate removal, selection drift, or exact credential replacement', async () => { @@ -578,13 +1039,13 @@ describe('main-process desktop credential service', () => { 'https://same.example.test/api/planner/drafts/draft-a/attachments/image-a', capturedRestA, ), { cancel: true }); assert.deepEqual(service.prepareRequest(capturedSocketA, { Cookie: 'socket=a' }, { resourceType: 'webSocket' }), { cancel: true }); - assert.deepEqual(service.prepareRequest( + assert.deepEqual((await service.prepareRequestAsync( 'https://same.example.test/api/side-effect', transportHeaders(activatedB.transportScope), - ).requestHeaders, { Authorization: `Bearer ${token('B')}` }); + )).requestHeaders, { Authorization: `Bearer ${token('B')}` }); const currentSocket = `wss://same.example.test/socket.io/?EIO=4&transport=websocket&proprDesktopTransportScope=${activatedB.transportScope}`; - assert.equal(service.prepareRequest(currentSocket, {}, { resourceType: 'webSocket' }).cancel, undefined); - assert.equal(service.prepareRequest(currentSocket, {}, { resourceType: 'webSocket' }).cancel, undefined); + assert.equal((await service.prepareRequestAsync(currentSocket, {}, { resourceType: 'webSocket' })).cancel, undefined); + assert.equal((await service.prepareRequestAsync(currentSocket, {}, { resourceType: 'webSocket' })).cancel, undefined); assert.deepEqual(service.prepareRequest('wss://same.example.test/socket.io/?transport=websocket', {}, { resourceType: 'webSocket', }), { cancel: true }); @@ -664,10 +1125,10 @@ describe('main-process desktop credential service', () => { `ws://localhost:3000/socket.io/?transport=websocket&proprDesktopTransportScope=${firstActivation.transportScope}`, {}, { resourceType: 'webSocket' }, ), { cancel: true }); - assert.equal(service.prepareRequest( + assert.equal((await service.prepareRequestAsync( `ws://localhost:3000/socket.io/?transport=websocket&proprDesktopTransportScope=${secondActivation.transportScope}`, {}, { resourceType: 'webSocket' }, - ).requestHeaders?.Authorization, `Bearer ${token('A')}`); + )).requestHeaders?.Authorization, `Bearer ${token('A')}`); }); it('never sends an A-origin bearer after the profile URL is edited to an attacker origin', async () => { @@ -754,7 +1215,7 @@ describe('main-process desktop credential service', () => { assert.match(staleResult.message, /connection changed.*try again/i); assert.deepEqual(await store.readCredential(profile.id), replacement); if (!currentActivation) return; - assert.deepEqual(service.prepareRequest('https://a.example.test/api/tasks', transportHeaders(currentActivation.transportScope, {})).requestHeaders, { + assert.deepEqual((await service.prepareRequestAsync('https://a.example.test/api/tasks', transportHeaders(currentActivation.transportScope, {}))).requestHeaders, { Authorization: `Bearer ${replacement.token}`, }); }); @@ -805,7 +1266,7 @@ describe('main-process desktop credential service', () => { assert.match(staleResult.message, /connection changed.*try again/i); assert.deepEqual(await store.readCredential(profile.id), replacement); if (!currentActivation) return; - assert.deepEqual(service.prepareRequest('https://b.example.test/api/tasks', transportHeaders(currentActivation.transportScope, {})).requestHeaders, { + assert.deepEqual((await service.prepareRequestAsync('https://b.example.test/api/tasks', transportHeaders(currentActivation.transportScope, {}))).requestHeaders, { Authorization: `Bearer ${replacement.token}`, }); }); @@ -883,10 +1344,10 @@ describe('main-process desktop credential service', () => { assert.deepEqual(await store.list(), { profiles: [profile], activeProfileId: profile.id }); assert.deepEqual(await store.readCredential(profile.id), oldCredential); - assert.deepEqual(service.prepareRequest( + assert.deepEqual((await service.prepareRequestAsync( 'https://a.example.test/api/tasks', transportHeaders(activated.transportScope), - ).requestHeaders, { Authorization: `Bearer ${oldCredential.token}` }); + )).requestHeaders, { Authorization: `Bearer ${oldCredential.token}` }); assert.equal(requests.some(request => request.url === 'https://a.example.test/api/desktop/tokens/current' && request.authorization === `Bearer ${oldCredential.token}`), false); }); @@ -1050,9 +1511,9 @@ describe('main-process desktop credential service', () => { assert.equal(ready.status, 'ready'); if (ready.status !== 'ready') return; const activeB = await service.activate(ready.activationTicket); - assert.deepEqual(service.prepareRequest( + assert.deepEqual((await service.prepareRequestAsync( `${profile.apiBaseUrl}/api/tasks`, transportHeaders(activeB.transportScope), - ).requestHeaders, { Authorization: `Bearer ${credentialB.token}` }); + )).requestHeaders, { Authorization: `Bearer ${credentialB.token}` }); const offlineDiagnostics: Array<{ code: string; status?: number }> = []; const offlineRestart = createCredentialService({ @@ -1097,7 +1558,8 @@ describe('main-process desktop credential service', () => { profiles: store, clientName: 'Test desktop', openPairingBrowser: async () => undefined, - fetch: async (_input, init) => { + fetch: async (input, init) => { + if (input.toString().endsWith('/api/desktop/discovery')) return json(discovery); terminalRetries += 1; return terminalRevocation(init); }, @@ -1207,7 +1669,8 @@ describe('main-process desktop credential service', () => { profiles: restarted, clientName: 'Restarted desktop', openPairingBrowser: async () => undefined, - fetch: async (_input, init) => { + fetch: async (input, init) => { + if (input.toString().endsWith('/api/desktop/discovery')) return json(discovery); retries += 1; assert.equal(new Headers(init?.headers).get('Authorization'), `Bearer ${credentialA.token}`); return terminalRevocation(init); @@ -1482,6 +1945,8 @@ describe('main-process desktop credential service', () => { return new Response(new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode('{')); + }, + pull() { bodyStarted.resolve(); }, cancel() { bodyCancelled = true; }, @@ -1596,7 +2061,7 @@ describe('main-process desktop credential service', () => { }, }); assert.deepEqual(await online.initialize(), { status: 'ready', retryPending: false }); - assert.equal(recoveryCalls, 2); + assert.equal(recoveryCalls, 4, 'each revocation is preceded by one unauthenticated discovery'); assert.deepEqual(await store.pendingRevocations(), []); }); @@ -1614,7 +2079,8 @@ describe('main-process desktop credential service', () => { profiles: store, clientName: 'Restarted after provisional crash', openPairingBrowser: async () => undefined, - fetch: async (_input, init) => { + fetch: async (input, init) => { + if (input.toString().endsWith('/api/desktop/discovery')) return json(discovery); calls += 1; assert.equal(new Headers(init?.headers).get('Authorization'), `Bearer ${token('C')}`); return new Response(null, { status: 204 }); diff --git a/apps/desktop/src/credential-service.ts b/apps/desktop/src/credential-service.ts index ac2f86514..c1b9c2fd8 100644 --- a/apps/desktop/src/credential-service.ts +++ b/apps/desktop/src/credential-service.ts @@ -15,6 +15,7 @@ import { DESKTOP_TRANSPORT_SCOPE_HEADER, DESKTOP_TRANSPORT_SCOPE_QUERY, canonicalProprHttpUrlOrigin, + isPublicInstanceIdentity, } from '@propr/shared'; import { type DesktopProfileInput, @@ -25,6 +26,7 @@ import { } from './shared/contract'; import { normalizeApiBaseUrl } from './security'; import type { PendingCredentialRevocation, ProfileStore, StoredCredential } from './profile-store'; +import type { DesktopConnectIdentityClaimSnapshot } from './connect-discovery'; const DEFINITIVE_INVALID_CODES = new Set([ 'INVALID_INSTANCE_TOKEN', @@ -51,6 +53,8 @@ export interface CredentialServiceDependencies { code: 'network' | 'http' | 'local-cleanup'; status?: number; }): void; + /** Main-owned Connect evidence; renderer input can never provide this snapshot. */ + snapshotConnectIdentityClaim?(profileId: string, origin: string): DesktopConnectIdentityClaimSnapshot; } export interface DesktopPairingBrowserRequest { @@ -76,6 +80,7 @@ interface ActiveCredential extends StoredCredential { profileGeneration: number; selectionGeneration: number; transportScope: string; + connectClaim: DesktopConnectIdentityClaimSnapshot; } interface PendingActivation { @@ -88,6 +93,7 @@ interface PendingActivation { activeProfileId: string | null; credential: StoredCredential; identityEpoch: string; + connectClaim: DesktopConnectIdentityClaimSnapshot; } type RequestHeaders = Record; @@ -329,6 +335,7 @@ export class DesktopCredentialService { readonly #pairingProtocol: PairingProtocolRequestOptions; readonly #reportRevocationFailure: NonNullable; readonly #revocationDeadlines: RevocationDeadlines; + readonly #snapshotConnectIdentityClaim: NonNullable; readonly #internalRequestKey = randomBytes(32).toString('base64url'); readonly #lifecycleController = new AbortController(); readonly #profileGenerations = new Map(); @@ -357,6 +364,11 @@ export class DesktopCredentialService { this.#pairingProtocol = dependencies.pairingProtocol ?? {}; this.#reportRevocationFailure = dependencies.reportRevocationFailure ?? (() => undefined); this.#revocationDeadlines = boundedRevocationDeadlines(dependencies.revocationDeadlines); + this.#snapshotConnectIdentityClaim = dependencies.snapshotConnectIdentityClaim ?? (() => ({ + status: 'unclaimed', + isCurrent: () => true, + beginCommit: () => () => undefined, + })); } async initialize(): Promise { @@ -529,6 +541,10 @@ export class DesktopCredentialService { const label = input.label?.trim(); if (!label || label.length > 80) throw new Error('Profile label must contain 1 to 80 characters'); const proposed = { ...input, id: input.id, label, apiBaseUrl: origin }; + const connectClaim = this.#snapshotConnectIdentityClaim(proposed.id, proposed.apiBaseUrl); + if (connectClaim.status === 'origin-mismatch' || connectClaim.status === 'pending') { + throw new Error('The ProPR Connect origin changed. Use the currently discovered instance.'); + } const baseline = await this.#profiles.readProfileCredential(proposed.id); this.#cancelPairingNow(proposed.id); if (this.#pendingActivation?.profileId === proposed.id) this.#pendingActivation = null; @@ -544,6 +560,18 @@ export class DesktopCredentialService { const client = this.#client(proposed.apiBaseUrl); try { + const discovery = await client.discoverDesktop(8_000, controller.signal); + if (!discovery.compatibility.compatible + || !discovery.desktopAuthentication.browserPairing + || !discovery.desktopAuthentication.instanceBearerTokens + || !discovery.desktopAuthentication.socketIoBearerAuthentication + || (connectClaim.status === 'claimed' + && connectClaim.publicInstanceIdentity !== discovery.publicInstanceIdentity)) { + throw new Error('The ProPR instance identity or desktop protocol changed. Approve the new instance again.'); + } + this.#assertPairingCurrent( + proposed.id, proposed.apiBaseUrl, profileGeneration, selectionGeneration, controller.signal, connectClaim, + ); const completed = await client.pairDesktop(this.#clientName, { ...this.#pairingTiming, binding: { @@ -555,7 +583,7 @@ export class DesktopCredentialService { signal: controller.signal, onApprovalRequired: async (approvalUrl, _expiresAt, pairingId) => { this.#assertPairingCurrent( - proposed.id, proposed.apiBaseUrl, profileGeneration, selectionGeneration, controller.signal, + proposed.id, proposed.apiBaseUrl, profileGeneration, selectionGeneration, controller.signal, connectClaim, ); await this.#openPairingBrowser({ apiBaseUrl: proposed.apiBaseUrl, @@ -566,22 +594,29 @@ export class DesktopCredentialService { }); provisional = completed; transient = { - version: 1, + version: 2, profileId: proposed.id, origin: proposed.apiBaseUrl, + publicInstanceIdentity: discovery.publicInstanceIdentity, token: completed.token, }; + this.#assertPairingCurrent( + proposed.id, proposed.apiBaseUrl, profileGeneration, selectionGeneration, controller.signal, connectClaim, + ); const journaled = await this.#profiles.journalPendingRevocation(transient, credentialGeneration); if ('stored' in journaled) { throw new Error('OS-backed secure storage is required for desktop pairing.'); } transientRevocation = journaled; this.#assertPairingCurrent( - proposed.id, proposed.apiBaseUrl, profileGeneration, selectionGeneration, controller.signal, + proposed.id, proposed.apiBaseUrl, profileGeneration, selectionGeneration, controller.signal, connectClaim, ); let activationError: unknown; for (let attempt = 0; attempt < 2; attempt += 1) { try { + this.#assertPairingCurrent( + proposed.id, proposed.apiBaseUrl, profileGeneration, selectionGeneration, controller.signal, connectClaim, + ); await client.activateDesktopPairing(completed, controller.signal); activationError = undefined; break; @@ -592,7 +627,7 @@ export class DesktopCredentialService { } if (activationError) throw activationError; this.#assertPairingCurrent( - proposed.id, proposed.apiBaseUrl, profileGeneration, selectionGeneration, controller.signal, + proposed.id, proposed.apiBaseUrl, profileGeneration, selectionGeneration, controller.signal, connectClaim, ); const committed = await this.#profiles.commitPairedProfile( proposed, @@ -600,9 +635,10 @@ export class DesktopCredentialService { baseline, () => !controller.signal.aborted && this.#generation(proposed.id) === profileGeneration - && this.#selectionGeneration === selectionGeneration, + && this.#selectionGeneration === selectionGeneration + && connectClaim.isCurrent(), () => this.#beginPairPublish( - proposed.id, profileGeneration, selectionGeneration, controller.signal, + proposed.id, profileGeneration, selectionGeneration, controller.signal, connectClaim, ), () => { publicationStarted = true; @@ -672,6 +708,13 @@ export class DesktopCredentialService { if (!input.id) throw new Error('Desktop profile id is required'); const origin = normalizeApiBaseUrl(input.apiBaseUrl ?? ''); if (!origin || origin !== input.apiBaseUrl) throw new Error('Invalid desktop API URL'); + const connectClaim = this.#snapshotConnectIdentityClaim(input.id, origin); + if (connectClaim.status === 'origin-mismatch' || connectClaim.status === 'pending') { + return { + status: 'authentication-required', + message: 'The ProPR Connect instance changed. Use the currently discovered instance and approve it again.', + }; + } const probeTicket = ++this.#latestProbeTicket; this.#pendingActivation = null; const operationGeneration = this.#generation(input.id); @@ -692,6 +735,28 @@ export class DesktopCredentialService { message: 'This instance requires authentication for public desktop discovery. Check its proxy configuration or update ProPR, then try again.', }; } + if (error instanceof ProprClientError && error.kind === 'invalid_response') { + try { + const current = await this.#profiles.readProfileCredential(input.id); + if (current.profile?.apiBaseUrl === origin && current.credential?.origin === origin) { + const removed = await this.#detachIdentityFailedCredential( + current.credential, + operationGeneration, + operationSelection, + probeTicket, + ); + if (!removed) { + return { status: 'offline', message: 'This connection changed while it was being checked. Try again.' }; + } + } + } catch { + return { status: 'offline', message: 'ProPR could not safely invalidate this instance credential.' }; + } + return { + status: 'authentication-required', + message: 'This endpoint returned invalid identity metadata. Approve it again to continue.', + }; + } return { status: 'offline', message: error instanceof Error @@ -700,9 +765,48 @@ export class DesktopCredentialService { }; } const authentication = authenticationSummary(discovery.desktopAuthentication); + if (!connectClaim.isCurrent()) { + return { + status: 'authentication-required', + message: 'The ProPR Connect instance changed. Use the currently discovered instance and approve it again.', + version: discovery.version, + authentication, + }; + } + const initial = await this.#profiles.readProfileCredential(input.id); + if (this.#generation(input.id) !== operationGeneration + || this.#selectionGeneration !== operationSelection + || this.#latestProbeTicket !== probeTicket) { + return { status: 'offline', message: 'This connection changed while it was being checked. Try again.' }; + } + const identityMismatched = initial.profile?.apiBaseUrl === origin + && initial.credential?.origin === origin + && (!isPublicInstanceIdentity(initial.credential.publicInstanceIdentity) + || initial.credential.publicInstanceIdentity !== discovery.publicInstanceIdentity); + if (identityMismatched) { + const removed = await this.#detachIdentityFailedCredential( + initial.credential!, + operationGeneration, + operationSelection, + probeTicket, + ); + if (!removed) { + return { status: 'offline', message: 'This connection changed while it was being checked. Try again.' }; + } + } if (!discovery.compatibility.compatible) { return { status: 'incompatible', message: discovery.compatibility.message, version: discovery.version }; } + if (!discovery.desktopAuthentication.browserPairing + || !discovery.desktopAuthentication.instanceBearerTokens + || !discovery.desktopAuthentication.socketIoBearerAuthentication) { + return { + status: 'authentication-required', + message: 'This instance does not support the complete secure desktop authentication protocol.', + version: discovery.version, + authentication, + }; + } if (!this.#profiles.security().available) { return { status: 'authentication-required', @@ -712,11 +816,22 @@ export class DesktopCredentialService { }; } - const initial = await this.#profiles.readProfileCredential(input.id); - if (this.#generation(input.id) !== operationGeneration - || this.#selectionGeneration !== operationSelection - || this.#latestProbeTicket !== probeTicket) { - return { status: 'offline', message: 'This connection changed while it was being checked. Try again.' }; + if (connectClaim.status === 'claimed' + && connectClaim.publicInstanceIdentity !== discovery.publicInstanceIdentity) { + return { + status: 'authentication-required', + message: 'The ProPR Connect instance changed. Use the currently discovered instance and approve it again.', + version: discovery.version, + authentication, + }; + } + if (identityMismatched) { + return { + status: 'authentication-required', + message: 'This endpoint now identifies as a different ProPR instance. Approve it again to continue.', + version: discovery.version, + authentication, + }; } if (initial.profile?.apiBaseUrl !== origin) { return { @@ -761,9 +876,11 @@ export class DesktopCredentialService { authentication, }; } - let response: Response; try { + if (!connectClaim.isCurrent()) { + return { status: 'offline', message: 'This connection changed while it was being checked. Try again.' }; + } response = await this.#authenticatedFetch( credential, '/api/auth/user', { cache: 'no-store', signal: operation.signal }, 8_000, ); @@ -775,6 +892,7 @@ export class DesktopCredentialService { if (this.#generation(input.id) !== operationGeneration || this.#selectionGeneration !== operationSelection || this.#latestProbeTicket !== probeTicket + || !connectClaim.isCurrent() || current.profile?.apiBaseUrl !== origin || current.credential?.origin !== origin) { return { status: 'offline', message: 'This connection changed while it was being checked. Try again.' }; @@ -783,6 +901,7 @@ export class DesktopCredentialService { || current.credential.version !== credential.version || current.credential.profileId !== credential.profileId || current.credential.origin !== credential.origin + || current.credential.publicInstanceIdentity !== credential.publicInstanceIdentity || current.credential.token !== credential.token) { return { status: 'offline', message: 'This connection changed while it was being checked. Try again.' }; } @@ -797,6 +916,7 @@ export class DesktopCredentialService { activeProfileId: current.activeProfileId, credential: { ...credential }, identityEpoch: current.identityEpoch!, + connectClaim, }; return { status: 'ready', version: discovery.version, authentication, activationTicket }; } @@ -872,6 +992,7 @@ export class DesktopCredentialService { profileGeneration: pending.profileGeneration, selectionGeneration: this.#selectionGeneration, transportScope, + connectClaim: pending.connectClaim, }; return { status: 'ready', @@ -934,6 +1055,7 @@ export class DesktopCredentialService { url: string, originalHeaders: RequestHeaders, details: { method?: string; resourceType?: string } = {}, + verifiedSocketCredential?: ActiveCredential, ): DesktopRequestDecision { if (this.#closed) return { cancel: true }; const headers = { ...originalHeaders }; @@ -972,7 +1094,8 @@ export class DesktopCredentialService { const active = this.#active; const activeIsCurrent = active !== null && this.#generation(active.profileId) === active.profileGeneration - && this.#selectionGeneration === active.selectionGeneration; + && this.#selectionGeneration === active.selectionGeneration + && active.connectClaim.isCurrent(); const isApiRequest = target?.pathname.startsWith('/api/') === true; const isSocketUpgrade = target?.pathname === '/socket.io/' && target.url.searchParams.get('transport') === 'websocket' @@ -982,7 +1105,7 @@ export class DesktopCredentialService { if (isSocketUpgrade && target) { const queryScopes = target.url.searchParams.getAll(DESKTOP_TRANSPORT_SCOPE_QUERY); if (queryScopes.length !== 1 || !TRANSPORT_SCOPE_PATTERN.test(queryScopes[0]) - || !activeIsCurrent || target.origin !== active.origin + || !activeIsCurrent || active !== verifiedSocketCredential || target.origin !== active.origin || queryScopes[0] !== active.transportScope) return { cancel: true }; headers.Authorization = `Bearer ${active.token}`; return { requestHeaders: headers }; @@ -996,6 +1119,51 @@ export class DesktopCredentialService { return { requestHeaders: headers }; } + /** Socket reconnects cross a fresh asynchronous identity gate before main attaches a bearer. */ + async prepareRequestAsync( + url: string, + originalHeaders: RequestHeaders, + details: { method?: string; resourceType?: string } = {}, + ): Promise { + const target = requestOrigin(url); + const isSocketUpgrade = target?.pathname === '/socket.io/' + && target.url.searchParams.get('transport') === 'websocket' + && (details.resourceType === 'webSocket' + || headerValues(originalHeaders, 'upgrade').some(value => value.toLowerCase() === 'websocket')); + if (!isSocketUpgrade) return this.prepareRequest(url, originalHeaders, details); + const active = this.#active; + if (!active || target.origin !== active.origin) return this.prepareRequest(url, originalHeaders, details); + try { + const discovery = await this.#client(active.origin).discoverDesktop(8_000, this.#lifecycleController.signal); + const stillCurrent = this.#active === active + && this.#generation(active.profileId) === active.profileGeneration + && this.#selectionGeneration === active.selectionGeneration + && active.connectClaim.isCurrent(); + if (!stillCurrent) return { cancel: true }; + const supportsRequest = discovery.compatibility.compatible + && discovery.desktopAuthentication.instanceBearerTokens + && discovery.desktopAuthentication.socketIoBearerAuthentication; + if (discovery.publicInstanceIdentity !== active.publicInstanceIdentity || !supportsRequest) { + await this.#detachIdentityFailedCredential( + active, + active.profileGeneration, + active.selectionGeneration, + ); + return { cancel: true }; + } + return this.prepareRequest(url, originalHeaders, details, active); + } catch (error) { + if (error instanceof ProprClientError && error.kind === 'invalid_response' && this.#active === active) { + await this.#detachIdentityFailedCredential( + active, + active.profileGeneration, + active.selectionGeneration, + ).catch(() => undefined); + } + return { cancel: true }; + } + } + authorizeRequest(url: string, originalHeaders: RequestHeaders): RequestHeaders { return this.prepareRequest(url, originalHeaders).requestHeaders ?? {}; } @@ -1121,6 +1289,13 @@ export class DesktopCredentialService { this.#revocationDeadlines.recordMs, ); try { + try { + const discovery = await this.#client(entry.credential.origin) + .discoverDesktop(Math.min(8_000, this.#revocationDeadlines.recordMs), record.controller.signal); + if (discovery.publicInstanceIdentity !== entry.credential.publicInstanceIdentity) return 'network'; + } catch { + return 'network'; + } const headers = new Headers({ Authorization: `Bearer ${entry.credential.token}`, [DESKTOP_REVOCATION_BINDING_HEADER]: entry.credentialGeneration, @@ -1221,16 +1396,21 @@ export class DesktopCredentialService { profileGeneration: number, selectionGeneration: number, signal: AbortSignal, + connectClaim: DesktopConnectIdentityClaimSnapshot, ): (() => void) | null { if (this.#publishingPair || signal.aborted || this.#generation(profileId) !== profileGeneration - || this.#selectionGeneration !== selectionGeneration) return null; + || this.#selectionGeneration !== selectionGeneration + || !connectClaim.isCurrent()) return null; + const releaseConnectClaim = connectClaim.beginCommit(); + if (!releaseConnectClaim) return null; this.#publishingPair = true; let released = false; return () => { if (released) return; released = true; this.#publishingPair = false; + releaseConnectClaim(); const waiters = this.#publishWaiters.splice(0); waiters.forEach(waiter => waiter()); }; @@ -1248,7 +1428,8 @@ export class DesktopCredentialService { #pendingIsCurrent(pending: PendingActivation): boolean { return this.#latestProbeTicket === pending.probeTicket && this.#generation(pending.profileId) === pending.profileGeneration - && this.#selectionGeneration === pending.selectionGeneration; + && this.#selectionGeneration === pending.selectionGeneration + && pending.connectClaim.isCurrent(); } #clearActiveIfCredential(credential: StoredCredential): void { @@ -1257,6 +1438,28 @@ export class DesktopCredentialService { && this.#active.token === credential.token) this.#active = null; } + async #detachIdentityFailedCredential( + credential: StoredCredential, + expectedProfileGeneration: number, + expectedSelectionGeneration: number, + expectedProbeTicket?: number, + ): Promise { + if (this.#generation(credential.profileId) !== expectedProfileGeneration + || this.#selectionGeneration !== expectedSelectionGeneration + || (expectedProbeTicket !== undefined && this.#latestProbeTicket !== expectedProbeTicket)) return false; + this.#invalidateProfileOperations(credential.profileId); + const invalidationGeneration = this.#generation(credential.profileId); + const removed = await this.#profiles.removeCredentialIfCurrent( + credential, + credential.origin, + () => this.#generation(credential.profileId) === invalidationGeneration + && this.#selectionGeneration === expectedSelectionGeneration + && (expectedProbeTicket === undefined || this.#latestProbeTicket === expectedProbeTicket), + ); + if (removed) this.#schedulePendingRevocationRetry(); + return removed; + } + #bumpGeneration(profileId: string): number { const generation = this.#generation(profileId) + 1; this.#profileGenerations.set(profileId, generation); @@ -1277,9 +1480,11 @@ export class DesktopCredentialService { profileGeneration: number, selectionGeneration: number, signal: AbortSignal, + connectClaim: DesktopConnectIdentityClaimSnapshot, ): void { if (signal.aborted || this.#generation(profileId) !== profileGeneration - || this.#selectionGeneration !== selectionGeneration) { + || this.#selectionGeneration !== selectionGeneration + || !connectClaim.isCurrent()) { throw new ProprClientError('Desktop pairing was cancelled.', { kind: 'aborted' }); } if (normalizeApiBaseUrl(origin) !== origin) throw new Error('Invalid desktop API URL'); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 4d0771614..1b86d8538 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -220,10 +220,10 @@ const configureSessionSecurity = (credentials: DesktopCredentialService): { desktopSession.setPermissionCheckHandler(() => false); desktopSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false)); desktopSession.webRequest.onBeforeSendHeaders((details, callback) => { - callback(credentials.prepareRequest(details.url, details.requestHeaders, { + void credentials.prepareRequestAsync(details.url, details.requestHeaders, { method: details.method, resourceType: details.resourceType, - })); + }).then(callback, () => callback({ cancel: true })); }); desktopSession.webRequest.onHeadersReceived((details, callback) => { callback({ @@ -630,7 +630,10 @@ const runPackagedTransportSmoke = async ( const profileA = await profiles.save({ id: profileId, label: 'Packaged transport A', apiBaseUrl: smoke.firstOrigin, }); - const storedA = await profiles.writeCredential({ version: 1, profileId, origin: smoke.firstOrigin, token: tokenA }); + const storedA = await profiles.writeCredential({ + version: 2, profileId, origin: smoke.firstOrigin, + publicInstanceIdentity: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', token: tokenA, + }); if (!storedA.stored) throw new Error('Production credential encryption was unavailable'); const storageWindows = await Promise.all([smoke.firstOrigin, smoke.secondOrigin].map(async origin => { @@ -738,7 +741,10 @@ const runPackagedTransportSmoke = async ( if (!precommitStorageCleared || !await storageState('absent')) { throw new Error('Same-ID URL edit did not clear both complete Electron origin stores'); } - const storedB = await profiles.writeCredential({ version: 1, profileId, origin: smoke.secondOrigin, token: tokenB }); + const storedB = await profiles.writeCredential({ + version: 2, profileId, origin: smoke.secondOrigin, + publicInstanceIdentity: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', token: tokenB, + }); if (!storedB.stored) throw new Error('Replacement credential encryption was unavailable'); const profileForRendererB = { id: profileId, name: 'Packaged transport B', baseUrl: smoke.secondOrigin, kind: 'local' }; @@ -1023,6 +1029,8 @@ if (!hasSingleInstanceLock) { reportRevocationFailure: diagnostic => { log('warn', 'desktop.credential_revocation.retry_pending', diagnostic); }, + snapshotConnectIdentityClaim: (profileId, origin) => + connectDiscovery.snapshotIdentityClaim(profileId, origin), }); const sessionSecurity = configureSessionSecurity(credentials); const credentialInitialization = await credentials.initialize(); diff --git a/apps/desktop/src/pairing-response-lifecycle.test.ts b/apps/desktop/src/pairing-response-lifecycle.test.ts index a8eb048d0..da6a7c738 100644 --- a/apps/desktop/src/pairing-response-lifecycle.test.ts +++ b/apps/desktop/src/pairing-response-lifecycle.test.ts @@ -5,6 +5,7 @@ import { join, relative } from 'node:path'; import { describe, it } from 'node:test'; import type { App, IpcMain, IpcMainInvokeEvent, Session } from 'electron'; import type { PairingProtocolRequestOptions } from '@propr/client'; +import { PROPR_API_COMPATIBILITY, PROPR_UI_COMPATIBILITY } from '@propr/shared'; import { DesktopCredentialService } from './credential-service'; import { registerIpcHandlers } from './ipc'; import type { LocalLifecycleController } from './lifecycle'; @@ -197,6 +198,21 @@ describe('desktop pairing service IPC native shutdown lifecycle', () => { ); const fetchImplementation: typeof globalThis.fetch = async (input, init) => { + if (input.toString().endsWith('/api/desktop/discovery')) return json({ + schemaVersion: 1, + product: 'ProPR', + version: '0.8.15', + apiCompatibility: PROPR_API_COMPATIBILITY, + uiCompatibility: PROPR_UI_COMPATIBILITY, + canonicalEndpoint: null, + publicInstanceIdentity: '123e4567-e89b-42d3-a456-426614174000', + desktopAuthentication: { + protocolVersion: 2, + browserPairing: true, + instanceBearerTokens: true, + socketIoBearerAuthentication: true, + }, + }); counts.fetchStart += 1; const url = input.toString(); const signal = init?.signal ?? undefined; @@ -322,7 +338,11 @@ describe('desktop pairing service IPC native shutdown lifecycle', () => { assert.equal(pendingBeforeShutdown.length, provisionalCouldExist ? 1 : 0); if (provisionalCouldExist) { assert.deepEqual(pendingBeforeShutdown[0].credential, { - version: 1, profileId, origin, token: provisionalToken, + version: 2, + profileId, + origin, + publicInstanceIdentity: '123e4567-e89b-42d3-a456-426614174000', + token: provisionalToken, }); } assert.equal(await store.readCredential(profileId), null); diff --git a/apps/desktop/src/pending-revocation-crash-fixture.ts b/apps/desktop/src/pending-revocation-crash-fixture.ts index fc2d72336..6710aee21 100644 --- a/apps/desktop/src/pending-revocation-crash-fixture.ts +++ b/apps/desktop/src/pending-revocation-crash-fixture.ts @@ -25,7 +25,24 @@ const service = new DesktopCredentialService({ profiles, clientName: 'Crash fixture', openPairingBrowser: async () => undefined, - fetch: async (_input, init) => { + fetch: async (input, init) => { + if (input.toString().endsWith('/api/desktop/discovery')) { + return new Response(JSON.stringify({ + schemaVersion: 1, + product: 'ProPR', + version: '0.8.15', + apiCompatibility: '2026-08-01', + uiCompatibility: '2026-08-01', + canonicalEndpoint: null, + publicInstanceIdentity: '123e4567-e89b-42d3-a456-426614174000', + desktopAuthentication: { + protocolVersion: 2, + browserPairing: true, + instanceBearerTokens: true, + socketIoBearerAuthentication: true, + }, + }), { headers: { 'Content-Type': 'application/json' } }); + } const authorization = new Headers(init?.headers).get('Authorization'); if (authorization !== `Bearer propr_it_${'A'.repeat(43)}`) { throw new Error('Pending revocation used the wrong credential'); diff --git a/apps/desktop/src/profile-store-crash-fixture.ts b/apps/desktop/src/profile-store-crash-fixture.ts index cd2b88520..ded27579c 100644 --- a/apps/desktop/src/profile-store-crash-fixture.ts +++ b/apps/desktop/src/profile-store-crash-fixture.ts @@ -36,9 +36,10 @@ if (requestedStep.startsWith('detach:')) { await store.commitPairedProfile( { id: 'profile-1', label: 'Replacement', apiBaseUrl: 'https://propr.example.com' }, { - version: 1, + version: 2, profileId: 'profile-1', origin: 'https://propr.example.com', + publicInstanceIdentity: '123e4567-e89b-42d3-a456-426614174000', token: `propr_it_${'B'.repeat(43)}`, }, baseline, diff --git a/apps/desktop/src/profile-store.test.ts b/apps/desktop/src/profile-store.test.ts index 105bd1ed0..5c486350d 100644 --- a/apps/desktop/src/profile-store.test.ts +++ b/apps/desktop/src/profile-store.test.ts @@ -43,6 +43,13 @@ const encryption = (available = true, backend = 'keychain'): EncryptionProvider }); const credential = (profileId: string, tokenCharacter = 'A') => ({ + version: 2 as const, + profileId, + origin: 'https://propr.example.com', + publicInstanceIdentity: '123e4567-e89b-42d3-a456-426614174000', + token: `propr_it_${tokenCharacter.repeat(43)}`, +}); +const legacyCredential = (profileId: string, tokenCharacter = 'A') => ({ version: 1 as const, profileId, origin: 'https://propr.example.com', @@ -78,12 +85,12 @@ const seedRecoveryMode = async ( })); await writeFile( join(credentials, `${legacyProfile.id}.bin`), - encryption().encrypt(JSON.stringify(credential(legacyProfile.id))), + encryption().encrypt(JSON.stringify(legacyCredential(legacyProfile.id))), ); return; } const slot = `${legacyProfile.id}.00000000-0000-4000-8000-000000000001.bin`; - await writeFile(join(credentials, slot), encryption().encrypt(JSON.stringify(credential(legacyProfile.id)))); + await writeFile(join(credentials, slot), encryption().encrypt(JSON.stringify(legacyCredential(legacyProfile.id)))); await writeFile(join(desktop, 'profiles.json'), JSON.stringify({ version: 2, activeProfileId: legacyProfile.id, @@ -251,7 +258,7 @@ describe('desktop profile store', () => { assert.equal((await readdir(join(desktop, 'credentials'))).length, 1); }); - it('migrates legacy fixed credentials through the atomic state pointer and removes the old slot', async () => { + it('fails legacy unbound credentials closed while preserving profile metadata', async () => { const directory = await createDirectory(); const desktop = join(directory, 'desktop'); const credentials = join(desktop, 'credentials'); @@ -263,21 +270,20 @@ describe('desktop profile store', () => { await writeFile(join(desktop, 'profiles.json'), JSON.stringify({ version: 1, activeProfileId: profile.id, profiles: [profile], })); - const legacyCredential = credential(profile.id, 'A'); - await writeFile(join(credentials, `${profile.id}.bin`), encryption().encrypt(JSON.stringify(legacyCredential))); + const oldCredential = legacyCredential(profile.id, 'A'); + await writeFile(join(credentials, `${profile.id}.bin`), encryption().encrypt(JSON.stringify(oldCredential))); const store = new ProfileStore(directory, encryption()); const migrated = await store.readProfileCredential(profile.id); - assert.deepEqual({ ...migrated, identityEpoch: undefined }, { - profile, credential: legacyCredential, identityEpoch: undefined, activeProfileId: profile.id, + assert.deepEqual(migrated, { + profile, credential: null, identityEpoch: null, activeProfileId: null, }); - assert.match(migrated.identityEpoch ?? '', /^[A-Za-z0-9_-]{22}$/); const state = JSON.parse(await readFile(join(desktop, 'profiles.json'), 'utf8')) as { version: number; credentialSlots: Record; }; assert.equal(state.version, 3); - assert.match(state.credentialSlots[profile.id], /^profile-1\.[0-9a-f-]{36}\.bin$/); - assert.deepEqual(await readdir(credentials), [state.credentialSlots[profile.id]]); + assert.deepEqual(state.credentialSlots, {}); + assert.deepEqual(await readdir(credentials), []); }); it('migrates the exact-head numeric unsealed journal only when its valid mirror matches exactly', async () => { @@ -748,9 +754,9 @@ describe('desktop profile store', () => { } else { const snapshot = await recovered.readProfileCredential(legacyProfile.id); assert.deepEqual(snapshot.profile, legacyProfile, `${mode}/${step}/${restart}`); - assert.deepEqual(snapshot.credential, credential(legacyProfile.id), `${mode}/${step}/${restart}`); - assert.equal(snapshot.activeProfileId, legacyProfile.id, `${mode}/${step}/${restart}`); - assert.match(snapshot.identityEpoch ?? '', /^[A-Za-z0-9_-]{22}$/, `${mode}/${step}/${restart}`); + assert.equal(snapshot.credential, null, `${mode}/${step}/${restart}`); + assert.equal(snapshot.activeProfileId, null, `${mode}/${step}/${restart}`); + assert.equal(snapshot.identityEpoch, null, `${mode}/${step}/${restart}`); } const state = JSON.parse(await readFile(join(desktop, 'profiles.json'), 'utf8')) as { version: number }; assert.equal(state.version, 3, `${mode}/${step}/${restart}`); diff --git a/apps/desktop/src/profile-store.ts b/apps/desktop/src/profile-store.ts index 329c6dbfa..c76f0916b 100644 --- a/apps/desktop/src/profile-store.ts +++ b/apps/desktop/src/profile-store.ts @@ -14,6 +14,7 @@ import { type FileHandle, } from 'node:fs/promises'; import { join } from 'node:path'; +import { isPublicInstanceIdentity } from '@propr/shared'; import type { DesktopProfile, DesktopProfileInput, @@ -26,9 +27,10 @@ const PROFILE_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/; const MAX_CREDENTIAL_LENGTH = 65_536; export interface StoredCredential { - version: 1; + version: 2; profileId: string; origin: string; + publicInstanceIdentity: string; token: string; } @@ -451,9 +453,10 @@ export class ProfileStore { pendingRevocationId?: string, ): Promise { const normalized = normalizedProfileInput(input); - if (credential.version !== 1 + if (credential.version !== 2 || credential.profileId !== normalized.id || credential.origin !== normalized.apiBaseUrl + || !isPublicInstanceIdentity(credential.publicInstanceIdentity) || typeof credential.token !== 'string' || credential.token.length > MAX_CREDENTIAL_LENGTH || !/^propr_it_[A-Za-z0-9_-]{43}$/.test(credential.token)) { @@ -632,9 +635,10 @@ export class ProfileStore { const value = JSON.parse(this.#encryption.decrypt(encrypted)) as unknown; if (!value || typeof value !== 'object') return null; const credential = value as Record; - if (credential.version !== 1 || credential.profileId !== profileId + if (credential.version !== 2 || credential.profileId !== profileId || typeof credential.origin !== 'string' || normalizeApiBaseUrl(credential.origin) !== credential.origin + || !isPublicInstanceIdentity(credential.publicInstanceIdentity) || typeof credential.token !== 'string' || !/^propr_it_[A-Za-z0-9_-]{43}$/.test(credential.token)) return null; return credential as unknown as StoredCredential; @@ -685,6 +689,7 @@ export class ProfileStore { && actual.version === expected.version && actual.profileId === expected.profileId && actual.origin === expected.origin + && actual.publicInstanceIdentity === expected.publicInstanceIdentity && actual.token === expected.token; } @@ -704,7 +709,8 @@ export class ProfileStore { async writeCredential(credential: StoredCredential): Promise<{ stored: true } | { stored: false; reason: 'encryption-unavailable' }> { const profileId = credential?.profileId; assertProfileId(profileId); - if (credential.version !== 1 || normalizeApiBaseUrl(credential.origin) !== credential.origin + if (credential.version !== 2 || normalizeApiBaseUrl(credential.origin) !== credential.origin + || !isPublicInstanceIdentity(credential.publicInstanceIdentity) || typeof credential.token !== 'string' || credential.token.length > MAX_CREDENTIAL_LENGTH || !/^propr_it_[A-Za-z0-9_-]{43}$/.test(credential.token)) { throw new Error('Credential must contain 1 to 65536 characters'); @@ -760,6 +766,7 @@ export class ProfileStore { || credential.version !== expected.version || credential.profileId !== expected.profileId || credential.origin !== expected.origin + || credential.publicInstanceIdentity !== expected.publicInstanceIdentity || credential.token !== expected.token) return false; await this.#moveCredentialToPending(state, profileId); await this.#writeState(state); @@ -773,7 +780,8 @@ export class ProfileStore { ): Promise { const profileId = credential?.profileId; assertProfileId(profileId); - if (credential.version !== 1 || normalizeApiBaseUrl(credential.origin) !== credential.origin + if (credential.version !== 2 || normalizeApiBaseUrl(credential.origin) !== credential.origin + || !isPublicInstanceIdentity(credential.publicInstanceIdentity) || typeof credential.token !== 'string' || credential.token.length > MAX_CREDENTIAL_LENGTH || !/^propr_it_[A-Za-z0-9_-]{43}$/.test(credential.token)) { throw new Error('Invalid desktop credential revocation material'); @@ -1149,20 +1157,27 @@ export class ProfileStore { || !/^[A-Za-z0-9_-]+$/.test(encoded)) throw new Error(RECOVERY_ERROR); const bytes = Buffer.from(encoded, 'base64url'); if (bytes.toString('base64url') !== encoded) throw new Error(RECOVERY_ERROR); - let credential: StoredCredential | null = null; + let credential: (StoredCredential & Record) | Record | null = null; try { - credential = JSON.parse(this.#encryption.decrypt(bytes)) as StoredCredential; + credential = JSON.parse(this.#encryption.decrypt(bytes)) as Record; } catch { if (!this.#wasPreviouslyAuthenticatedSlot(state, slot, encoded)) throw new Error(RECOVERY_ERROR); } const profileId = SLOT_PATTERN.exec(slot)?.[1]; - if (credential && (credential.version !== 1 || credential.profileId !== profileId + const isLegacyCredential = credential?.version === 1 + && credential.profileId === profileId + && typeof credential.origin === 'string' + && normalizeApiBaseUrl(credential.origin) === credential.origin + && typeof credential.token === 'string' + && /^propr_it_[A-Za-z0-9_-]{43}$/.test(credential.token); + if (credential && !isLegacyCredential && (credential.version !== 2 || credential.profileId !== profileId || typeof credential.origin !== 'string' || normalizeApiBaseUrl(credential.origin) !== credential.origin + || !isPublicInstanceIdentity(credential.publicInstanceIdentity) || typeof credential.token !== 'string' || !/^propr_it_[A-Za-z0-9_-]{43}$/.test(credential.token))) throw new Error(RECOVERY_ERROR); const pending = Object.values(state.pendingRevocations).find(record => record.slot === slot); - if (credential && pending + if (credential && !isLegacyCredential && pending && (pending.profileId !== credential.profileId || pending.origin !== credential.origin)) { throw new Error(RECOVERY_ERROR); } @@ -1337,20 +1352,9 @@ export class ProfileStore { credentialEpochs: {}, pendingRevocations: {}, }; - const entries = await readdir(this.#credentialsDirectory, { withFileTypes: true }); - for (const entry of entries) { - const match = /^([a-zA-Z0-9][a-zA-Z0-9_-]{0,63})\.bin$/.exec(entry.name); - if (!match || !entry.isFile()) continue; - const profileId = match[1]; - const bytes = await readFile(join(this.#credentialsDirectory, entry.name)); - const slot = `${profileId}.${randomUUID()}.bin`; - const slotPath = join(this.#credentialsDirectory, slot); - await writeFile(slotPath, bytes, { mode: 0o600 }); - await this.#fsyncFile(slotPath); - state.credentialSlots[profileId] = slot; - state.credentialEpochs[profileId] = randomBytes(16).toString('base64url'); - } - await this.#flushDirectoryIfSupported(this.#credentialsDirectory); + // Pre-identity credentials cannot safely be presented to any endpoint. + // Keep profiles, but deliberately migrate without their bearer slots. + state.activeProfileId = null; await this.#writeState(state); } else if (parsed.version === 2) { state = { @@ -1358,12 +1362,11 @@ export class ProfileStore { generation: '0', activeProfileId: parsed.activeProfileId, profiles: parsed.profiles.map(profile => ({ ...profile })), - credentialSlots: { ...parsed.credentialSlots }, - credentialEpochs: Object.fromEntries( - Object.keys(parsed.credentialSlots).map(profileId => [profileId, randomBytes(16).toString('base64url')]), - ), + credentialSlots: {}, + credentialEpochs: {}, pendingRevocations: {}, }; + if (Object.keys(parsed.credentialSlots).length > 0) state.activeProfileId = null; await this.#writeState(state); } else { state = parsed; @@ -1373,6 +1376,31 @@ export class ProfileStore { } } + // Version-3 stores created before public identity binding authenticate at + // the journal layer, but their credential payloads are intentionally not + // usable. Remove those references locally before any caller can read a + // bearer; re-pairing creates a fresh identity-bound generation. + let removedUnboundCredential = false; + for (const [profileId, slot] of Object.entries(state.credentialSlots)) { + let credential: StoredCredential | null; + try { credential = await this.#readCredentialSlot(slot, profileId); } + catch { continue; } // Preserve material while the OS credential backend is temporarily unavailable. + if (credential) continue; + delete state.credentialSlots[profileId]; + delete state.credentialEpochs[profileId]; + if (state.activeProfileId === profileId) state.activeProfileId = null; + removedUnboundCredential = true; + } + for (const [id, pending] of Object.entries(state.pendingRevocations)) { + let credential: StoredCredential | null; + try { credential = await this.#readCredentialSlot(pending.slot, pending.profileId); } + catch { continue; } + if (credential) continue; + delete state.pendingRevocations[id]; + removedUnboundCredential = true; + } + if (removedUnboundCredential) await this.#writeState(state); + const referenced = new Set([ ...Object.values(state.credentialSlots), ...Object.values(state.pendingRevocations).map(record => record.slot), diff --git a/apps/desktop/src/release-workflow.test.ts b/apps/desktop/src/release-workflow.test.ts index 13f7ca1a1..bf0a28a03 100644 --- a/apps/desktop/src/release-workflow.test.ts +++ b/apps/desktop/src/release-workflow.test.ts @@ -42,6 +42,30 @@ const installedWindowsAppTest = normalizeWorkflowText(readFileSync( fileURLToPath(new URL('../scripts/test-installed-windows-app.ps1', import.meta.url)), 'utf8', )); +const installedWindowsAppSupervisor = normalizeWorkflowText(readFileSync( + fileURLToPath(new URL('../scripts/run-installed-windows-app-harness.ps1', import.meta.url)), + 'utf8', +)); +const installedWindowsAppCleanup = normalizeWorkflowText(readFileSync( + fileURLToPath(new URL('../scripts/cleanup-installed-windows-app.ps1', import.meta.url)), + 'utf8', +)); +const installedWindowsAppWorkflowCleanupWrapper = normalizeWorkflowText(readFileSync( + fileURLToPath(new URL('../scripts/run-installed-windows-app-workflow-cleanup.ps1', import.meta.url)), + 'utf8', +)); +const installedWindowsAppWorkflowCleanup = normalizeWorkflowText(readFileSync( + fileURLToPath(new URL('../scripts/run-installed-windows-app-workflow-cleanup-body.ps1', import.meta.url)), + 'utf8', +)); +const installedWindowsAppSupervisorBehaviorTest = normalizeWorkflowText(readFileSync( + fileURLToPath(new URL('../scripts/test-installed-windows-app-supervisor.ps1', import.meta.url)), + 'utf8', +)); +const installedWindowsAppSupervisorFixture = normalizeWorkflowText(readFileSync( + fileURLToPath(new URL('../scripts/test-installed-windows-app-supervisor-fixture.ps1', import.meta.url)), + 'utf8', +)); const preflightAppTokenPermissions = (preflight: string): string[] => ( [...preflight.matchAll(/^\s+permission-([a-z-]+): (read|write)$/gm)] @@ -323,7 +347,7 @@ describe('desktop trusted release workflow', () => { `${jobName} retained a deferred Windows authority gate`); } assert.equal(workflow.match(/\*Machine-Setup\.msi/g)?.length, 3); - assert.equal(workflow.match(/test-installed-windows-app\.ps1/g)?.length, 2); + assert.equal(workflow.match(/run-installed-windows-app-harness\.ps1/g)?.length, 2); assert.equal(workflow.match(/PROPR_DESKTOP_WINDOWS_INSTALLED_APP=1/g)?.length, 2); assert.doesNotMatch(forgeConfig, /extraResource|windows-authority|postPackage/); assert.match(forgeConfig, /buildWindowsMachineInstaller/); @@ -363,7 +387,7 @@ describe('desktop trusted release workflow', () => { assert.match(installedWindowsAppTest, /SetAccessRuleProtection\(\$true, \$false\)/); assert.match(installedWindowsAppTest, /S-1-5-18/); assert.match(installedWindowsAppTest, /S-1-5-32-544/); - assert.match(installedWindowsAppTest, /Remove-SmokeUserDataDirectory \$smokeUserDataDirectory/); + assert.match(installedWindowsAppTest, /Remove-SmokeUserDataDirectory \$smokeOwnershipRecord/); assert.match(installedWindowsAppTest, /propr:\/\/connect/); assert.match(installedWindowsAppTest, /deferred Windows update authority resource/); assert.match(installedWindowsAppTest, /\[Environment\]::GetFolderPath\(\[Environment\+SpecialFolder\]::CommonPrograms\)/); @@ -394,7 +418,7 @@ describe('desktop trusted release workflow', () => { assert.doesNotMatch(releaseArchitecture, /electron-winstaller|7z-(?:x64|arm64)\.exe/); }); - test('bounds and diagnoses installed Windows process lifecycles on x64 and ARM64', () => { + test('supplementary lint retains installed Windows worker lifecycle contracts', () => { assert.doesNotMatch(installedWindowsAppTest, /(?:^|\s)-Wait(?:\s|$)/); assert.equal(installedWindowsAppTest.match(/Start-Process/g)?.length, 1); assert.match(installedWindowsAppTest, /\$msiTimeoutMilliseconds = 10 \* 60 \* 1000/); @@ -453,7 +477,11 @@ describe('desktop trusted release workflow', () => { assert.match(installedWindowsAppTest, /\$item\.Attributes -band \[IO\.FileAttributes\]::ReparsePoint/); assert.match(installedWindowsAppTest, /\[IO\.FileStream\]::new\(/); assert.doesNotMatch(installedWindowsAppTest, /New-Object IO\.FileStream\(/); - assert.doesNotMatch(installedWindowsAppTest, /Get-ChildItem[^\n]*smoke|ReadAll|ReadToEnd/); + const evidenceReader = installedWindowsAppTest.slice( + installedWindowsAppTest.indexOf('function Get-SmokeEventEvidence'), + installedWindowsAppTest.indexOf("Write-Stage 'INSTALL' 'BEGIN'"), + ); + assert.doesNotMatch(evidenceReader, /Get-ChildItem[^\n]*smoke|ReadAll|ReadToEnd/); const smokeEventAllowlist = installedWindowsAppTest.match( /\$smokeEventCodes = \[ordered\]@\{([\s\S]*?)\n\}/, ); @@ -496,7 +524,7 @@ describe('desktop trusted release workflow', () => { ); assert.match( applicationExitSection, - /catch \{\n\s+\$waitFailure = \$_\n\s+\} finally \{\n\s+try \{\n\s+Close-RedirectedApplicationStreams \$applicationLaunch[\s\S]*?\} finally \{\n\s+\$applicationLaunch\.Process\.Dispose\(\)\n\s+\$applicationLaunch = \$null/, + /catch \{\n\s+\$waitFailure = \$_\n\s+\} finally \{\n\s+try \{[\s\S]*?Close-RedirectedApplicationStreams \$applicationLaunch[\s\S]*?\} finally \{\n\s+\$applicationLaunch\.Process\.Dispose\(\)\n\s+\$applicationLaunch = \$null/, ); assert.ok( applicationExitSection.indexOf('Wait-BoundedProcess `') @@ -529,23 +557,770 @@ describe('desktop trusted release workflow', () => { assert.match( installedWindowsAppTest, - /\} catch \{\n\s+\$primaryFailure = \$_\n\s+throw\n\} finally \{\n\s+\$cleanupFailed = \$false[\s\S]*Invoke-Msi @\('\/x'[\s\S]*Remove-SmokeUserDataDirectory \$smokeUserDataDirectory/, + /\} catch \{\n\s+\$primaryFailure = \$_\n\s+throw\n\} finally \{\n\s+\$cleanupFailed = \$false[\s\S]*Assert-InstallerArtifactAuthority[\s\S]*Invoke-Msi @\([\s\S]*'\/x', \[string\]\$ownershipState\.InstallerProductCode[\s\S]*Remove-SmokeUserDataDirectory \$smokeOwnershipRecord/, ); assert.match(installedWindowsAppTest, /Get-CimInstance -ClassName Win32_UserProfile/); assert.match(installedWindowsAppTest, /Remove-LocalUser -Name \$testUser -ErrorAction Stop/); - assert.match(installedWindowsAppTest, /Remove-Item -LiteralPath \$installRoot -Recurse -Force -ErrorAction Stop/); + assert.match( + installedWindowsAppTest, + /Get-ChildItem -LiteralPath \$installRoot -Force -ErrorAction Stop[\s\S]*Remove-Item -LiteralPath \$installRoot -Force -ErrorAction Stop/, + ); for (const section of [job('package', 'finalize'), job('release-package', 'release-finalize')]) { assert.match(section, /- platform: win32\n\s+arch: x64\n/); assert.match(section, /- platform: win32\n\s+arch: arm64\n/); - assert.equal(section.match(/test-installed-windows-app\.ps1/g)?.length, 1); + assert.equal(section.match(/run-installed-windows-app-harness\.ps1/g)?.length, 1); + assert.equal(section.match(/test-installed-windows-app-supervisor\.ps1/g)?.length, 1); + assert.equal(section.match(/run-installed-windows-app-workflow-cleanup\.ps1/g)?.length, 1); + assert.match(section, /if: always\(\) && matrix\.platform == 'win32'/); + assert.match(section, /-OwnershipManifest \$env:PROPR_WINDOWS_INSTALLED_APP_MANIFEST/); + assert.match(section, /-ExpectedRunId \$env:PROPR_WINDOWS_INSTALLED_APP_RUN_ID/); } }); - test('uses bounded network logon impersonation with secure native credential cleanup', () => { - const nativeLogon = installedWindowsAppTest.match( - /Add-Type -TypeDefinition @'\n([\s\S]*?)\n'@/, + test('runs executable supervisor acceptance on both Windows architectures and keeps supplementary contracts', () => { + assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-BootstrapTimeout/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-OperationDeadlineAndTreeTermination/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-NegativeWorkerExitFinalization/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-FailClosedMarkers/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-LiveCancellationAndRedaction/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-PreExistingCleanupOwnership/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Start-ExternallyInterruptibleSupervisor/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Invoke-WorkflowCleanupController/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-PreExistingAppPathsAuthority/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Assert-ProcessTreeGone/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /WindowsIdentity\]::GetCurrent\(\)/); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop/, + ); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Get-Acl -LiteralPath \$canonicalLocalPath/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /FileAttributes\]::ReparsePoint/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Assert-RunnerProfileUnchanged/); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /PROPR_WINDOWS_SUPERVISOR_OWNERSHIP:PRE_EXISTING_AUTHORITIES:PRESERVED/, + ); + assert.doesNotMatch( + installedWindowsAppSupervisorBehaviorTest, + /CreateProfile|DeleteProfile|userenv\.dll/, + ); + assert.match(installedWindowsAppSupervisorFixture, /Start-FixtureDescendant/); + + assert.match(installedWindowsAppSupervisor, /JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000/); + assert.match(installedWindowsAppSupervisor, /AssignProcessToJobObject\(handle, processHandle\)/); + assert.match(installedWindowsAppSupervisor, /TerminateJobObject\(handle, exitCode\)/); + assert.match(installedWindowsAppSupervisor, /\$job\.AddProcess\(\$worker\.Handle\)/); + assert.match(installedWindowsAppSupervisor, /\[void\]\$ownershipReadyEvent\.Set\(\)/); + assert.ok( + installedWindowsAppSupervisor.indexOf('$job.AddProcess($worker.Handle)') + < installedWindowsAppSupervisor.indexOf('[void]$ownershipReadyEvent.Set()'), + ); + assert.match(installedWindowsAppTest, /\$ownershipHandshakeTimeoutMilliseconds = 5 \* 1000/); + assert.match(installedWindowsAppTest, /\$ownershipReady\.WaitOne\(\$ownershipHandshakeTimeoutMilliseconds\)/); + assert.match(installedWindowsAppSupervisor, /if \(!\$worker\.Start\(\)\)[^\n]+\n\s+\$workerStarted = \$true\n\s+\$bootstrapStopwatch = \[Diagnostics\.Stopwatch\]::StartNew\(\)/); + assert.match(installedWindowsAppSupervisor, /\[ProPRBoundedMarkerReader\]::ReadAsync\(\$Path\)/); + assert.match(installedWindowsAppSupervisor, /\$readTask\.Wait\(\$TimeoutMilliseconds\)/); + assert.match( + installedWindowsAppSupervisor, + /\$job\.TerminateAndWait\(\$TerminationExitCode, \$WatchdogTerminationMilliseconds\)/, + ); + assert.match(installedWindowsAppSupervisor, /\$workerTreeTerminated = Stop-OwnedWorker 125/); + assert.doesNotMatch(installedWindowsAppSupervisor, /Stop-OwnedWorker \(\[uint32\]\$exitCode\)/); + assert.match(installedWindowsAppSupervisorFixture, /'NEGATIVE_EXIT'[\s\S]*exit -1/); + assert.match(installedWindowsAppSupervisor, /\$worker\.WaitForExit\(\$WatchdogTerminationMilliseconds\)/); + assert.match( + installedWindowsAppSupervisor, + /if \(\$workerTreeTerminated -and \$postTerminationCleanupAuthorized\) \{[\s\S]*Invoke-PostTerminationCleanup/, + ); + assert.match(installedWindowsAppSupervisor, /Invoke-PostTerminationCleanup/); + assert.match(installedWindowsAppSupervisor, /\$cleanupRequired = \$terminateOwnedTree -or \$workerStarted/); + assert.match( + installedWindowsAppSupervisor, + /PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:COMPLETE/, + ); + assert.match(installedWindowsAppCleanup, /Remove-OwnedProfiles/); + assert.match(installedWindowsAppCleanup, /Promote-UncapturedOwnedProfiles/); + assert.match( + installedWindowsAppCleanup, + /\$matchingRecords = @\(\)[\s\S]*Resolve-ValidatedOwnedProfilePath[\s\S]*\$matchingRecords\.Count -ne 1[\s\S]*Remove-CimInstance/, + ); + for (const script of [installedWindowsAppTest, installedWindowsAppCleanup]) { + assert.match(script, /Resolve-SystemProfilesDirectory/); + assert.match(script, /-Name 'ProfilesDirectory' -ErrorAction Stop/); + assert.match(script, /Resolve-CanonicalNonReparseDirectory/); + assert.match(script, /FileAttributes\]::ReparsePoint/); + assert.match(script, /Split-Path -Parent \$canonicalLocalPath/); + assert.match(script, /Split-Path -Leaf \$canonicalLocalPath/); + assert.match(script, /profile local path is not the exact owned direct child of ProfilesDirectory/); + assert.match( + script, + /Resolve-ValidatedOwnedProfilePath[\s\S]*profile ownership changed immediately before deletion[\s\S]*Remove-CimInstance/, + ); + } + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /mismatched durable profile path did not fail closed[\s\S]*mismatched profile path discarded ACTIVE recovery authority/, + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /alternate ProfilesDirectory leaf did not fail closed[\s\S]*alternate ProfilesDirectory leaf discarded ACTIVE recovery authority/, + ); + assert.match(installedWindowsAppCleanup, /Remove-OwnedRegistryKey/); + assert.match(installedWindowsAppCleanup, /Remove-OwnedDirectory/); + assert.match(installedWindowsAppCleanup, /APP_PATH/); + assert.match(installedWindowsAppCleanup, /HKEY_CURRENT_USER\\Software\\ProPR\\Desktop/); + assert.match(installedWindowsAppCleanup, /Restore-OwnedRegistryValue/); + assert.match(installedWindowsAppCleanup, /Write-EmptyOwnershipReceipt/); + assert.match(installedWindowsAppCleanup, /Get-RegistryTreeIdentity/); + assert.match(installedWindowsAppCleanup, /Get-FileIdentity/); + assert.match(installedWindowsAppCleanup, /Get-DirectoryIdentity/); + assert.match(installedWindowsAppCleanup, /Get-FileSystemTreeIdentity/); + assert.match(installedWindowsAppCleanup, /Assert-MsiManagedFileSystemAuthority/); + assert.match( + installedWindowsAppCleanup, + /Assert-MsiManagedFileSystemAuthority \$manifest\n\s+Assert-InstallerArtifactAuthority \$manifest\n\s+\$msi = Start-Process msiexec\.exe/, + ); + assert.doesNotMatch(installedWindowsAppCleanup, /AllowProvisionalProductOwnership/); + assert.doesNotMatch(installedWindowsAppCleanup, /allowProvisionalMsiUninstall/); + assert.match( + installedWindowsAppCleanup, + /\$allowAuthenticatedMsiUninstall[\s\S]*MsiTransactionState -ceq 'COMMITTED'[\s\S]*Start-Process msiexec\.exe/, + ); + assert.match( + installedWindowsAppCleanup, + /provisional registry evidence cannot authorize manual cleanup/, + ); + assert.match( + installedWindowsAppTest, + /MsiTransactionState = 'PENDING'[\s\S]*if \(!\$script:msiInstallCompleted\)[\s\S]*Get-DirectoryIdentity \$installRoot/, + ); + assert.match(installedWindowsAppTest, /MsiTransactionState = 'ROLLED_BACK_CLEAN'/); + assert.match(installedWindowsAppTest, /MsiTransactionState = 'COMMITTED'/); + assert.match(installedWindowsAppTest, /Assert-ExactCleanMsiBaselineAfterRollback/); + assert.match( + installedWindowsAppTest, + /Assert-MsiProductIsUnregistered \(\[string\]\$ownershipState\.InstallerProductCode\)/, + ); + assert.match(installedWindowsAppCleanup, /Assert-MsiProductIsUnregistered/); + assert.match(installedWindowsAppSupervisor, /Wait-MsiCriticalTransactionReceipt/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /DURING_MSI/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /DURING_OWNERSHIP_CAPTURE/); + assert.match( + installedWindowsAppTest, + /Registry::HKEY_LOCAL_MACHINE\\Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\propr-desktop\.exe/, + ); + assert.match(installedWindowsAppTest, /APP_PATH_ASSERTION/); + assert.match(installedWindowsAppTest, /APP_PATH_ABSENCE_ASSERTION/); + assert.match(installedWindowsAppTest, /APP_PATH_FALLBACK/); + assert.match(installedWindowsAppTest, /HKCU_INSTALLED_ASSERTION/); + assert.match(installedWindowsAppTest, /HKCU_INSTALLED_ABSENCE_ASSERTION/); + assert.match(installedWindowsAppTest, /HKCU_INSTALLED_FALLBACK/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-HkcuInstalledValueOwnership/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /OWNED_RESOURCES_NORMAL_SUCCESS/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /typed authenticated empty-state receipt/); + assert.match( + installedWindowsAppTest, + /Get-RegistryTreeIdentity \$appPathsRegistryPath[\s\S]*refusing to uninstall over executable metadata[\s\S]*Invoke-Msi @\([\s\S]*'\/x', \[string\]\$ownershipState\.InstallerProductCode/, + ); + assert.match( + installedWindowsAppTest, + /Assert-MsiManagedFileSystemAuthority[\s\S]*Assert-InstallerArtifactAuthority[\s\S]*Invoke-Msi @\([\s\S]*'\/x', \[string\]\$ownershipState\.InstallerProductCode/, + ); + assert.match(installedWindowsAppSupervisor, /Get-InstallerAuthority \$Installer/); + assert.ok( + installedWindowsAppSupervisor.indexOf('Get-InstallerAuthority $Installer') + < installedWindowsAppSupervisor.indexOf('if (!$worker.Start())'), + 'installer authority must be captured before the worker starts', + ); + for (const field of [ + 'InstallerEntryIdentity', 'InstallerSha256', 'InstallerProductCode', + ]) { + assert.match(installedWindowsAppSupervisor, new RegExp(field)); + assert.match(installedWindowsAppTest, new RegExp(field)); + assert.match(installedWindowsAppCleanup, new RegExp(field)); + } + assert.match(installedWindowsAppSupervisor, /SchemaVersion = 3/); + assert.match(installedWindowsAppTest, /SchemaVersion = 3/); + assert.match(installedWindowsAppCleanup, /SchemaVersion -ne 3/); + assert.match( + installedWindowsAppCleanup, + /\[IO\.FileShare\]'ReadWrite, Delete'[\s\S]*ReadHandle\(\s*\$manifestStream\.SafeFileHandle,/, + ); + assert.match( + installedWindowsAppCleanup, + /ReadEntry\(\$manifestPath, \$false\) -cne\s+\$manifestEntryIdentity/, + ); + assert.match( + installedWindowsAppCleanup, + /HANDSHAKE','FILE_AUTHORITY','UTF8_DECODE','JSON_PARSE','EXACT_KEY_SET',[\s\S]*'BOOLEAN_TYPES','TRANSACTION_ENUM','SCHEMA_TYPE_STATE','RUN_ID_FORMAT',[\s\S]*'INSTALLER_ENTRY_ID_FORMAT','INSTALLER_SHA256_FORMAT','INSTALLER_PRODUCT_CODE_FORMAT',[\s\S]*'INITIAL_ACTIVE_MATCH',[\s\S]*'INITIAL_INSTALLER_AUTHORITY_RECHECK','EMPTY_RECEIPT_WRITE'/, + ); + assert.match( + installedWindowsAppCleanup, + /\$manifest\.Fixture\.PSObject\.BaseObject\.GetType\(\) -ne \[bool\][\s\S]*\$manifest\.BaselineClean\.PSObject\.BaseObject\.GetType\(\) -ne \[bool\][\s\S]*\$manifest\.InstallAttempted\.PSObject\.BaseObject\.GetType\(\) -ne \[bool\]/, + ); + assert.match( + installedWindowsAppCleanup, + /\$manifest\.RunId\.PSObject\.BaseObject[\s\S]*GetType\(\) -ne \[string\][\s\S]*\$manifest\.InstallerEntryIdentity\.PSObject\.BaseObject[\s\S]*\$manifest\.InstallerSha256\.PSObject\.BaseObject[\s\S]*\$manifest\.InstallerProductCode\.PSObject\.BaseObject/, ); + assert.match( + installedWindowsAppCleanup, + /\$manifest\.RunId = \[string\]\$runIdBaseObject[\s\S]*\$manifest\.InstallerProductCode = \[string\]\$installerProductCodeBaseObject/, + ); + assert.match( + installedWindowsAppCleanup, + /if \(\$PSVersionTable\.PSEdition -ceq 'Core'\) \{[\s\S]*\[IO\.File\]::Move\(\$temporaryPath, \$Path, \$true\)[\s\S]*\} else \{[\s\S]*\[ProPRAtomicFile\]::ReplaceSameDirectory\(\$temporaryPath, \$Path\)/, + ); + assert.match( + installedWindowsAppCleanup, + /class ProPRAtomicFile[\s\S]*String\.Equals\(temporaryDirectory, destinationDirectory,[\s\S]*StringComparison\.OrdinalIgnoreCase\)[\s\S]*MoveFileExW\(temporaryFullPath, destinationFullPath,[\s\S]*MOVEFILE_REPLACE_EXISTING \| MOVEFILE_WRITE_THROUGH\)[\s\S]*Marshal\.GetLastWin32Error\(\)[\s\S]*new Win32Exception\(error/, + ); + assert.doesNotMatch(installedWindowsAppCleanup, /\[IO\.File\]::Replace\(/); + assert.match( + installedWindowsAppCleanup, + /\[IO\.File\]::Move\(\$temporaryPath, \$Path, \$true\)/, + ); + assert.match( + installedWindowsAppCleanup, + /\$replacementCompleted = \$false[\s\S]*\$replacementCompleted = \$true\n\s+\} finally \{\n\s+if \(!\$replacementCompleted\) \{ \[IO\.File\]::Delete\(\$temporaryPath\) \}/, + ); + assert.match( + installedWindowsAppCleanup, + /\$emptyReceipt = \$Manifest\.PSObject\.Copy\(\)[\s\S]*\$emptyReceipt\.State = 'EMPTY'[\s\S]*Write-DurableOwnershipManifest \$Path \$emptyReceipt/, + ); + assert.match( + installedWindowsAppSupervisor, + /if \(\$fixtureNoMarkerDiagnostic\)[\s\S]*-FixtureValidationDiagnostic/, + ); + assert.match( + installedWindowsAppSupervisor, + /if \(\$fixtureNoMarkerDiagnostic\) \{[\s\S]*RedirectStandardOutput = \$true[\s\S]*RedirectStandardError = \$true/, + ); + assert.ok( + installedWindowsAppSupervisor.indexOf('$cleanupJob.AddProcess($cleanupProcess.Handle)') + < installedWindowsAppSupervisor.indexOf('[void]$cleanupReadyEvent.Set()'), + 'cleanup diagnostic child must enter its Job Object before ownership release', + ); + assert.ok( + installedWindowsAppSupervisor.indexOf('[void]$cleanupReadyEvent.Set()') + < installedWindowsAppSupervisor.indexOf('$cleanupDiagnosticDrain.Start($cleanupProcess)'), + 'cleanup diagnostic ownership must be released before redirected stream drains begin', + ); + assert.match(installedWindowsAppSupervisor, /class ProPRCleanupDiagnosticDrain/); + assert.match(installedWindowsAppSupervisor, /StandardOutputByteLimit = 96/); + assert.match(installedWindowsAppSupervisor, /StandardOutputLineLimit = 1/); + assert.match(installedWindowsAppSupervisor, /StandardErrorByteLimit = 0/); + assert.match(installedWindowsAppSupervisor, /StandardErrorLineLimit = 0/); + assert.match( + installedWindowsAppSupervisor, + /\\ACLEANUP_VALIDATION_PHASE:[\s\S]*INITIAL_INSTALLER_AUTHORITY_RECHECK\|[\s\S]*EMPTY_RECEIPT_WRITE\)\\r\?\\n\\z/, + ); + assert.match(installedWindowsAppSupervisor, /\$cleanupHostPath = \$hostPath/); + assert.match( + installedWindowsAppSupervisor, + /if \(\$fixtureWindowsPowerShellCleanup\)[\s\S]*System32\\WindowsPowerShell\\v1\.0\\powershell\.exe/, + ); + assert.match( + installedWindowsAppSupervisor, + /function Get-CanonicalManifestIdentifiers[\s\S]*ToLowerInvariant\(\)[\s\S]*\[Guid\]::TryParseExact\([\s\S]*ToString\('B'\)\.ToUpperInvariant\(\)/, + ); + assert.doesNotMatch( + installedWindowsAppSupervisor, + /InstallerEntryIdentity = \[string\]\$InstallerAuthority\.EntryIdentity[\s\S]*InstallerProductCode = \[string\]\$InstallerAuthority\.ProductCode/, + 'the 3af4800 capture/display representation must not be persisted as the identifier wire format', + ); + assert.match( + installedWindowsAppSupervisor, + /\$roundTrip = ConvertFrom-Json[\s\S]*\$roundTrip\.RunId -cne \$identifiers\.RunId[\s\S]*\$roundTrip\.InstallerProductCode -cne[\s\S]*\$identifiers\.InstallerProductCode/, + ); + assert.match( + installedWindowsAppCleanup, + /\[Console\]::Out\.WriteLine\(\s*'CLEANUP_VALIDATION_PHASE:' \+ \$Phase/, + ); + assert.doesNotMatch( + installedWindowsAppCleanup, + /\[Console\]::Out\.WriteLine\([\s\S]{0,120}PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:CLEANUP_VALIDATION_PHASE/, + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /CLEANUP_VALIDATION_PHASE:[\s\S]*HANDSHAKE\|FILE_AUTHORITY\|UTF8_DECODE\|JSON_PARSE\|EXACT_KEY_SET\|[\s\S]*BOOLEAN_TYPES\|TRANSACTION_ENUM\|SCHEMA_TYPE_STATE\|RUN_ID_FORMAT\|[\s\S]*INSTALLER_ENTRY_ID_FORMAT\|INSTALLER_SHA256_FORMAT\|INSTALLER_PRODUCT_CODE_FORMAT\|[\s\S]*INITIAL_INSTALLER_AUTHORITY_RECHECK\|EMPTY_RECEIPT_WRITE/, + ); + assert.match( + installedWindowsAppSupervisor, + /\$cleanupProcess\.ExitCode -in @\(20,21\)/, + ); + assert.match( + installedWindowsAppCleanup, + /\$cleanupValidationPhase = 'INITIAL_INSTALLER_AUTHORITY_RECHECK'\n\s+Assert-InstallerArtifactAuthority \$manifest\n\s+\$manifestValidated = \$true\n\s+\$cleanupValidationPhase = 'EMPTY_RECEIPT_WRITE'\n\s+Write-EmptyOwnershipReceipt/, + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /separate scenario runs the same supervisor-written initial ACTIVE[\s\S]*Windows PowerShell 5\.1 cleanup reader\/finalizer/, + ); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-WindowsPowerShellCleanupCompatibility/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /NO_MARKER_WINDOWS_POWERSHELL/); + assert.doesNotMatch( + installedWindowsAppCleanup, + /Start-Process msiexec\.exe[\s\S]{0,180}`"\$resolvedInstaller`"/, + ); + assert.match( + installedWindowsAppCleanup, + /Start-Process msiexec\.exe -ArgumentList @\(\n\s+'\/x', \[string\]\$manifest\.InstallerProductCode/, + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /same-path installer replacement did not fail closed/, + ); + assert.match(installedWindowsAppSupervisorBehaviorTest, /ACTIVE recovery authority/); + assert.match(installedWindowsAppTest, /TreeIdentity = \$script:installRootOwnedTreeIdentity/); + assert.match(installedWindowsAppTest, /EntryIdentity = \$script:shortcutOwnedEntryIdentity/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /mismatched App Paths ownership identity did not fail closed/); + assert.match(installedWindowsAppWorkflowCleanup, /ProPRWorkflowCleanupJob/); + assert.match(installedWindowsAppWorkflowCleanup, /QueryInformationJobObject/); + assert.match(installedWindowsAppWorkflowCleanup, /WaitForNoActiveProcesses/); + assert.match(installedWindowsAppWorkflowCleanup, /TerminateAndWait/); + assert.ok( + installedWindowsAppWorkflowCleanup.indexOf('$cleanupJob.AddProcess($cleanupProcess.Handle)') + < installedWindowsAppWorkflowCleanup.indexOf('$outputDrain.Start($cleanupProcess)'), + 'cleanup root must enter the Job Object before redirected output drains begin', + ); + assert.ok( + installedWindowsAppWorkflowCleanup.indexOf('$cleanupJob.AddProcess($cleanupProcess.Handle)') + < installedWindowsAppWorkflowCleanup.indexOf('[void]$cleanupReadyEvent.Set()'), + 'cleanup root must enter the Job Object before worker ownership is released', + ); + assert.ok( + installedWindowsAppCleanup.indexOf('$ownershipReady.WaitOne(5000)') + < installedWindowsAppCleanup.indexOf("Add-Type -TypeDefinition @'"), + 'cleanup worker ownership handshake must precede cold type loading', + ); + assert.match(installedWindowsAppSupervisorBehaviorTest, /early-initialization child cleanup/); + assert.match(installedWindowsAppCleanup, /workflow-cleanup-early-processes\.json/); + assert.match(installedWindowsAppWorkflowCleanup, /MANIFEST_VALIDATION_FAILURE/); + assert.match(installedWindowsAppWorkflowCleanup, /OWNED_RESOURCE_CLEANUP_FAILURE/); + assert.match(installedWindowsAppWorkflowCleanup, /ProPRWorkflowCleanupOutputDrain/); + assert.match(installedWindowsAppWorkflowCleanup, /StreamReader reader/); + assert.match(installedWindowsAppWorkflowCleanup, /reader\.ReadAsync/); + assert.match(installedWindowsAppWorkflowCleanup, /STREAM_DRAIN_(?:TIMEOUT|FAILURE)/); + assert.match(installedWindowsAppWorkflowCleanup, /CHILD_STDERR/); + assert.match(installedWindowsAppWorkflowCleanup, /WorkflowCleanupControllerPhase/); + assert.match(installedWindowsAppWorkflowCleanup, /WorkflowCleanupControllerLine/); + assert.match(installedWindowsAppWorkflowCleanup, /Set-CaughtControllerFailure/); + assert.doesNotMatch(installedWindowsAppWorkflowCleanup, /Console\]::SetError|\btrap\b|controllerBody/); + assert.match(installedWindowsAppWorkflowCleanup, /\[Console\]::Out\.WriteLine/); + assert.equal(installedWindowsAppWorkflowCleanup.match(/\[Console\]::Out\.WriteLine/g)?.length, 2); + assert.doesNotMatch(installedWindowsAppWorkflowCleanup, /Write-Host/); + assert.match( + installedWindowsAppWorkflowCleanup, + /Add-Type -TypeDefinition @'[\s\S]*'@\n\ntry \{\n\$controllerPhase = 'PARAMETER_VALIDATION'[\s\S]*\$controllerPhase = 'PROCESS_WAIT'[\s\S]*\n\} catch \{\n\s+Set-CaughtControllerFailure \$_\n\}/, + ); + assert.doesNotMatch(installedWindowsAppWorkflowCleanup, /\$invokeController|StartupFailureClass/); + assert.match( + installedWindowsAppWorkflowCleanupWrapper, + /run-installed-windows-app-workflow-cleanup-body\.ps1/, + ); + assert.match( + installedWindowsAppWorkflowCleanupWrapper, + /\[object\]\$OwnershipManifest[\s\S]*\[object\]\$Installer[\s\S]*\[object\]\$ExpectedRunId/, + ); + assert.match( + installedWindowsAppWorkflowCleanupWrapper, + /'PARSER'[\s\S]*'PARAMETER_BINDING'[\s\S]*'TYPE_LOAD'[\s\S]*'OTHER'/, + ); + assert.match(installedWindowsAppWorkflowCleanupWrapper, /Write-StartupFailure \$_/); + assert.equal( + installedWindowsAppWorkflowCleanupWrapper.match(/\[Console\]::Out\.WriteLine/g)?.length, + 2, + ); + assert.doesNotMatch( + installedWindowsAppWorkflowCleanupWrapper, + /Console\]::SetError|Write-(?:Error|Host)|\btrap\b/, + ); + assert.match(installedWindowsAppWorkflowCleanup, /CancelAndFinish/); + assert.doesNotMatch( + installedWindowsAppWorkflowCleanup, + /add_(?:Output|Error)DataReceived|Begin(?:Output|Error)ReadLine/, + ); + assert.match( + installedWindowsAppWorkflowCleanup, + /if \(\$fixedResult -ceq 'COMPLETE' -and \$cleanupTreeZeroVerified -and/, + ); + assert.match( + installedWindowsAppSupervisor, + /if \(\$fixedCleanupResult -eq \$true -and !\$workflowManagedManifest\)/, + ); + assert.match(installedWindowsAppSupervisorBehaviorTest, /OWNED_RESOURCES_REPLACED_THEN_DEADLINE/); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /OWNED_EXECUTABLE_REPLACED_THEN_DEADLINE/, + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /OWNED_SHORTCUT_REPLACED_THEN_DEADLINE/, + ); + assert.match(installedWindowsAppSupervisorBehaviorTest, /replacement executable was removed or changed/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /replacement shortcut was removed or changed/); + assert.match( + installedWindowsAppSupervisorFixture, + /function Initialize-FixtureDirectoryIdentity \{[\s\S]*?Add-Type -TypeDefinition/, + ); + assert.doesNotMatch( + installedWindowsAppSupervisorFixture.slice( + 0, + installedWindowsAppSupervisorFixture.indexOf('function Initialize-FixtureDirectoryIdentity'), + ), + /Add-Type/, + ); + assert.match( + installedWindowsAppSupervisorFixture, + /'OWNED_RESOURCES_THEN_DEADLINE' \{[\s\S]*Write-FixtureMarker[\s\S]*New-OwnedFixtureResources/, + ); + const controllerStatusParser = installedWindowsAppSupervisorBehaviorTest.indexOf( + '$statusMatch = Get-WorkflowCleanupControllerStatusMatch', + ); + assert.notEqual(controllerStatusParser, -1); + assert.ok( + controllerStatusParser + < installedWindowsAppSupervisorBehaviorTest.indexOf('if ($errorOutput.Length -ne 0)'), + 'controller fixed stdout must be parsed before bounded stderr classification', + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /PROPR_WORKFLOW_CLEANUP_FIXTURE:\{0\}:STATUS:\{1\}:EXIT_CODE:\{2\}/, + ); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Get-SanitizedControllerStartupDiagnostic/); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /STARTUP_CLASS:\{0\}:PROCESS_EXIT:\{1\}:LINE:\{2\}/, + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /'PARSER'[\s\S]*'PARAMETER_BINDING'[\s\S]*'TYPE_LOAD'[\s\S]*'OTHER'/, + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /OWNED_RESOURCES_FOREIGN_CHILD_THEN_DEADLINE/, + ); + assert.match(installedWindowsAppSupervisorBehaviorTest, /in-place foreign child was removed or changed/); + for (const checkpoint of [ + 'SMOKE_BEFORE_PROMOTION_THEN_DEADLINE', + 'SMOKE_AFTER_PROMOTION_THEN_DEADLINE', + 'SMOKE_AFTER_ARTIFACTS_THEN_DEADLINE', + 'SMOKE_FOREIGN_DESCENDANT_THEN_DEADLINE', + 'SMOKE_TOKEN_MISMATCH_THEN_DEADLINE', + ]) { + assert.match(installedWindowsAppSupervisorBehaviorTest, new RegExp(checkpoint)); + assert.match(installedWindowsAppSupervisorFixture, new RegExp(checkpoint)); + } + assert.match(installedWindowsAppSupervisorBehaviorTest, /foreign-smoke-in-place/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-PrimaryWorkerFallbackForeignDescendants/); + assert.match(installedWindowsAppSupervisorFixture, /PRIMARY_FALLBACK_FOREIGN_DESCENDANTS/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /primary install fallback removed or changed/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /primary shortcut fallback removed or changed/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Get-SanitizedSupervisorMarkerDiagnostic/); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /SUPERVISOR_EXIT:\{0\}:BOOTSTRAP_TIMED_OUT:\{1\}:LAST_VALID_NONE:\{2\}/, + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /POST_TERMINATION_CLEANUP:\{3\}:SUBPHASE:\{4\}:CLEANUP_CHILD_EXIT:\{5\}/, + ); + const laterNativeDiagnostics = installedWindowsAppSupervisorBehaviorTest.slice( + installedWindowsAppSupervisorBehaviorTest.indexOf( + 'function Get-SanitizedCriticalCancellationDiagnostic', + ), + installedWindowsAppSupervisorBehaviorTest.indexOf('function Assert-OwnedResourcesGone'), + ); + assert.match(laterNativeDiagnostics, /\$outputByteLimit = 4096/); + assert.match(laterNativeDiagnostics, /\$outputLineLimit = 32/); + assert.match(laterNativeDiagnostics, /\$outputLineByteLimit = 192/); + assert.match( + laterNativeDiagnostics, + /MSI_TRANSACTION:\{1\}:' \+\s*'POST_TERMINATION_CLEANUP:\{2\}:AUTHORITY_STATE:\{3\}/, + ); + assert.match( + laterNativeDiagnostics, + /'GRACE','ROLLED_BACK_CLEAN'|GRACE\|COMMITTED\|ROLLED_BACK_CLEAN\|UNPROVEN/, + ); + assert.match(laterNativeDiagnostics, /'PROVISIONAL'[\s\S]*'NONPROVISIONAL'/); + assert.match( + laterNativeDiagnostics, + /EXIT_CODE:\{0\}:RESULT:\{1\}:CONTROLLER_STATUS:\{2\}:' \+\s*'REPORTED_EXIT_CODE:\{3\}\{4\}/, + ); + assert.match(laterNativeDiagnostics, /ASCII\.GetByteCount\(\$diagnostic\) -gt 256/); + assert.match(laterNativeDiagnostics, /if \(\$controllerStatus -ceq 'STARTUP_FAILURE'\)/); + assert.match( + laterNativeDiagnostics, + /\$startupClass -cnotin @\('PARSER','PARAMETER_BINDING','TYPE_LOAD','OTHER'\)/, + ); + assert.match(laterNativeDiagnostics, /\$startupProcessExit = 'INVALID'/); + assert.match(laterNativeDiagnostics, /\$startupLine = 'INVALID'/); + assert.match(laterNativeDiagnostics, /\^\[1-9\]\[0-9\]\{0,5\}\$/); + assert.match(laterNativeDiagnostics, /\$parsedStartupLine -le 999999/); + assert.match( + laterNativeDiagnostics, + /STARTUP_CLASS:\{0\}:STARTUP_PROCESS_EXIT:\{1\}:' \+\s*'STARTUP_LINE:\{2\}/, + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /Get-WorkflowCleanupControllerStatusMatch[\s\S]*workflow cleanup parser accepted malformed startup metadata/, + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /valid bounded startup metadata was not preserved[\s\S]*invalid startup metadata did not fail closed to fixed sentinels[\s\S]*non-startup cleanup diagnostic included startup-only metadata/, + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /did not publish durable nonprovisional authority:\$duringCaptureDiagnostic/, + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /standalone cleanup did not retry to exact success after authority restoration:\$replacementRetryDiagnostic/, + ); + assert.match( + installedWindowsAppSupervisor, + /FIXTURE_FINALIZATION:' \+\s*'WORKER_TREE_TERMINATION:\{0\}'\) -f/, + ); + assert.match( + installedWindowsAppSupervisor, + /FIXTURE_FINALIZATION:' \+\s*'CLEANUP_CHILD_EXIT:\{0\}'\) -f/, + ); + assert.match(installedWindowsAppCleanup, /\$initialActiveFixtureManifest/); + assert.match( + installedWindowsAppCleanup, + /Write-EmptyOwnershipReceipt \$manifestPath \$manifest/, + ); + const primaryFallbackFixture = installedWindowsAppSupervisorFixture.slice( + installedWindowsAppSupervisorFixture.indexOf('function Test-PrimaryFallbackForeignDescendants'), + installedWindowsAppSupervisorFixture.indexOf('function Start-FixtureDescendant'), + ); + assert.doesNotMatch(primaryFallbackFixture, /Initialize-FixtureDirectoryIdentity|Add-Type/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /CONTROLLER_PARAMETER_VALIDATION_PARAMETERS_/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /InjectTerminationFailure/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /termination failure discarded authenticated recovery authority/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-ProvisionalUserMarkerOwnership/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /provisional username authorized replacement-account deletion/); + assert.match(installedWindowsAppTest, /-Description \$userOwnershipMarker/); + assert.match(installedWindowsAppCleanup, /provisional local-user ownership marker does not match/); + assert.doesNotMatch(installedWindowsAppCleanup, /\$skipMsiUninstall/); + const ownedDirectoryCleanup = installedWindowsAppCleanup.slice( + installedWindowsAppCleanup.indexOf('function Remove-OwnedDirectory'), + installedWindowsAppCleanup.indexOf('function Remove-OwnedFile'), + ); + assert.doesNotMatch(ownedDirectoryCleanup, /Remove-Item[^\n]*-Recurse/); + assert.match(ownedDirectoryCleanup, /owned directory contains an unexpected descendant/); + assert.match(ownedDirectoryCleanup, /Get-ChildItem[^\n]*-Force/); + assert.match(installedWindowsAppCleanup, /Resolve-SmokeDirectoryAuthority/); + assert.match(installedWindowsAppCleanup, /Remove-OwnedSmokeDirectory/); + assert.match(installedWindowsAppCleanup, /Get-FileSystemEntryIdentity/); + const ownedFileCleanup = installedWindowsAppCleanup.slice( + installedWindowsAppCleanup.indexOf('function Remove-OwnedFile'), + installedWindowsAppCleanup.indexOf('function Remove-OwnedRegistryKey'), + ); + assert.match( + ownedFileCleanup, + /Record\.EntryIdentity[\s\S]*Get-FileSystemEntryIdentity \$path \$false/, + ); + assert.ok( + ownedFileCleanup.indexOf('Get-FileSystemEntryIdentity $path $false') + < ownedFileCleanup.indexOf('Remove-Item -LiteralPath $path'), + 'owned file entry identity must be checked immediately before deletion', + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /OWNED_EXECUTABLE_BYTE_IDENTICAL_REPLACED_THEN_DEADLINE/, + ); + assert.match(installedWindowsAppSupervisorBehaviorTest, /LINE_COUNT:\{0\}:STDERR_COUNT:\{1\}/); + assert.match(installedWindowsAppCleanup, /smoke user-data object owner is not authorized/); + assert.match(installedWindowsAppCleanup, /smoke user-data object ACL is not authorized/); + assert.match(installedWindowsAppCleanup, /entries\.Count -ge 50000/); + const smokeCleanup = installedWindowsAppCleanup.slice( + installedWindowsAppCleanup.indexOf('function Remove-OwnedSmokeDirectory'), + installedWindowsAppCleanup.indexOf('function Remove-OwnedDirectory'), + ); + assert.doesNotMatch(smokeCleanup, /Remove-Item[^\n]*-Recurse/); + assert.match(smokeCleanup, /Get-ChildItem[^\n]*-Force/); + assert.match( + installedWindowsAppTest, + /Write-DurableOwnershipToken[\s\S]*Promote-SmokeOwnershipRecord[\s\S]*SHORTCUT_PRESENT_PROBE/, + ); + assert.match( + installedWindowsAppTest, + /CreatorSid = \[Security\.Principal\.WindowsIdentity\]::GetCurrent\(\)\.User\.Value/, + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /replacement install tree was removed or changed/, + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /timed-out workflow cleanup discarded authenticated recovery authority[\s\S]*failed workflow cleanup discarded authenticated recovery authority[\s\S]*retry to fixed cleanup success/, + ); + const fixedResultWrite = installedWindowsAppWorkflowCleanup.indexOf( + 'Write-FixedResult $fixedResult', + ); + assert.ok( + fixedResultWrite > installedWindowsAppWorkflowCleanup.indexOf('$resource.Dispose()') + && fixedResultWrite > installedWindowsAppWorkflowCleanup.indexOf( + 'if ($fixedResult -ceq \'COMPLETE\' -and $validatedManifestPath)', + ), + 'fixed controller evidence must be emitted after bounded finalization', + ); + assert.doesNotMatch( + installedWindowsAppSupervisorBehaviorTest, + /workflowCleanup\.(?:Error|StandardError)|failedCleanup\.(?:Error|StandardError)/, + ); + for (const result of ['COMPLETE', 'FAILED', 'TIMED_OUT']) { + assert.match( + installedWindowsAppWorkflowCleanup, + new RegExp(`PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:\\$Result|["']${result}["']`), + ); + } + assert.match(installedWindowsAppSupervisor, /exit \$exitCode/); + assert.match( + installedWindowsAppSupervisor, + /foreach \(\$resource in @\(\$job, \$worker, \$ownershipReadyEvent, \$cancellationEvent\)\)/, + ); + assert.match(installedWindowsAppSupervisor, /try \{ \$resource\.Dispose\(\) \} catch/); + + assert.match(installedWindowsAppTest, /\[IO\.FileOptions\]::WriteThrough/); + assert.equal(installedWindowsAppTest.match(/\.Flush\(\$true\)/g)?.length, 4); + assert.match( + installedWindowsAppTest, + /\$record = '\{0\}\|\{1\}\|\{2\}\|\{3\}' -f \$deadline, \$Stage, \$Substage, \$Status/, + ); + assert.match( + installedWindowsAppSupervisor, + /\(\?BEGIN\|COMPLETE\|FAILED\)/, + ); + assert.match( + installedWindowsAppSupervisor, + /PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:\{0\}:\{1\}:\{2\}:TIMED_OUT/, + ); + assert.match( + installedWindowsAppSupervisor, + /PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:BOOTSTRAP:TIMED_OUT/, + ); + assert.match( + installedWindowsAppSupervisor, + /PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:BOOTSTRAP:FAILED/, + ); + assert.match( + installedWindowsAppSupervisor, + /PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:ACCEPTED:\{0\}:\{1\}:\{2\}/, + ); + assert.match( + installedWindowsAppSupervisor, + /PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:LAST_VALID:\{0\}:\{1\}:\{2\}/, + ); + assert.match( + installedWindowsAppTest, + /PROPR_WINDOWS_INSTALLED_SMOKE:OPERATION:\{0\}:\{1\}:\{2\}' -f `[\s\S]{0,100}\[Console\]::Out\.Flush\(\)/, + ); + + const markerWriter = installedWindowsAppTest.match( + /function Write-WatchdogMarker\(([\s\S]*?)\n\}/, + ); + assert.ok(markerWriter); + const operationAllowlist = markerWriter[1].match( + /\[ValidateSet\(\n([\s\S]*?)\n\s+\)\]\[string\]\$Substage/, + ); + assert.ok(operationAllowlist); + const operations = [...operationAllowlist[1].matchAll(/'([A-Z_]+)'/g)] + .map(match => match[1]); + assert.deepEqual(operations, [ + 'PATHS', + 'BASELINE', + 'MSI_INSTALL', + 'OWNERSHIP_CAPTURE', + 'INSTALL_TREE_SCAN', + 'APPLICATION_IMAGE', + 'PROTOCOL_ASSERTION', + 'APP_PATH_ASSERTION', + 'HKCU_INSTALLED_ASSERTION', + 'SHORTCUT_ASSERTION', + 'USER_CREATE', + 'USER_SID', + 'SMOKE_DATA_CREATE', + 'SHORTCUT_PRESENT_PROBE', + 'ALTERNATE_USER_START', + 'APPLICATION_WAIT', + 'STREAM_DRAIN', + 'EVIDENCE_INSPECTION', + 'MSI_UNINSTALL', + 'INSTALL_TREE_ASSERTION', + 'PROTOCOL_ABSENCE_ASSERTION', + 'APP_PATH_ABSENCE_ASSERTION', + 'HKCU_INSTALLED_ABSENCE_ASSERTION', + 'SHORTCUT_FILE_ASSERTION', + 'SHORTCUT_FOLDER_ASSERTION', + 'SHORTCUT_ABSENCE_PROBE', + 'SMOKE_DATA_REMOVE', + 'PROFILE_LOOKUP', + 'PROFILE_REMOVE', + 'USER_LOOKUP', + 'USER_REMOVE', + 'INSTALL_ROOT_FALLBACK', + 'PROTOCOL_FALLBACK', + 'APP_PATH_FALLBACK', + 'HKCU_INSTALLED_FALLBACK', + 'SHORTCUT_FALLBACK', + ]); + for (const operation of operations) { + assert.ok( + installedWindowsAppTest.match(new RegExp(`'${operation}'`, 'g'))!.length >= 2, + `${operation} must be allowlisted and reached by a bounded marker path`, + ); + } + assert.match( + installedWindowsAppTest, + /Write-WatchdogMarker \$Stage \$Substage \$TimeoutMilliseconds 'BEGIN'[\s\S]*Write-WatchdogMarker \$Stage \$Substage \$TimeoutMilliseconds 'COMPLETE'[\s\S]*Write-WatchdogMarker \$Stage \$Substage \$TimeoutMilliseconds 'FAILED'/, + ); + + const diagnosticSources = `${installedWindowsAppSupervisor}\n${installedWindowsAppTest}`; + assert.doesNotMatch( + diagnosticSources, + /Write-(?:Host|Warning|Error|Verbose|Debug|Information)[^\n]*(?:\$password|\$credential|\$Installer|\$installerPath|\$testUser|\$UserName|\$Domain|\$Arguments|\$record|\$bytes)/i, + ); + }); + + test('supplementary lint retains fail-closed installed-app cleanup guards', () => { + assert.match( + installedWindowsAppTest, + /if \(\$installRootExistedBeforeInstall -or \$protocolExistedBeforeInstall -or[\s\S]*\$appPathsExistedBeforeInstall -or[\s\S]*\$startMenuShortcutFolderExistedBeforeInstall\) \{\n\s+throw 'installed-app harness requires an unowned clean machine baseline'/, + ); + assert.match(installedWindowsAppTest, /\$script:testUserCreatedByRun = \$true/); + assert.match( + installedWindowsAppTest, + /if \(\$testUserCreatedByRun -and \$null -ne \$testUserSid\)[\s\S]*!\$ownedUser\.SID\.Equals\(\$testUserSid\)[\s\S]*Remove-LocalUser/, + ); + assert.match( + installedWindowsAppTest, + /\$matchingRecords = @\(\)[\s\S]*foreach \(\$record in \$ownedProfileRecords\)[\s\S]*\$matchingRecords\.Count -ne 1[\s\S]*Remove-CimInstance -InputObject \$profile/, + ); + assert.match( + installedWindowsAppTest, + /if \(\$installRootCreatedByRun -and \(Test-Path -LiteralPath \$installRoot\)\)[\s\S]*Get-ChildItem -LiteralPath \$installRoot -Force[\s\S]*Remove-Item -LiteralPath \$installRoot -Force/, + ); + assert.match( + installedWindowsAppTest, + /if \(\$protocolCreatedByRun -and[\s\S]*Get-RegistryTreeIdentity \$protocolRegistryPath[\s\S]*Remove-Item -LiteralPath \$protocolRegistryPath -Recurse/, + ); + assert.match( + installedWindowsAppTest, + /if \(\$appPathsCreatedByRun -and[\s\S]*Get-RegistryTreeIdentity \$appPathsRegistryPath[\s\S]*Remove-Item -LiteralPath \$appPathsRegistryPath -Recurse/, + ); + assert.match( + installedWindowsAppTest, + /if \(\$createdByRun\) \{[\s\S]*Get-ChildItem -LiteralPath \$path -Force[\s\S]*Remove-Item -LiteralPath \$path -Force/, + ); + }); + + test('uses bounded network logon impersonation with secure native credential cleanup', () => { + const nativeLogon = [...installedWindowsAppTest.matchAll( + /Add-Type -TypeDefinition @'\n([\s\S]*?)\n'@/g, + )].find((match) => match[1].includes('public static class ProPRWindowsLogon')); assert.ok(nativeLogon); assert.match(nativeLogon[1], /using Microsoft\.Win32\.SafeHandles;/); assert.match(nativeLogon[1], /public const int LOGON32_LOGON_NETWORK = 3;/); @@ -676,6 +1451,8 @@ describe('desktop trusted release workflow', () => { 'MSI_UNINSTALL', 'INSTALL_TREE', 'PROTOCOL', + 'APP_PATH', + 'HKCU_INSTALLED', 'SHORTCUT_FILE', 'SHORTCUT_FOLDER', 'ORDINARY_USER_ABSENCE_PROBE', @@ -684,6 +1461,8 @@ describe('desktop trusted release workflow', () => { 'USER', 'INSTALL_ROOT_FALLBACK', 'PROTOCOL_FALLBACK', + 'APP_PATH_FALLBACK', + 'HKCU_INSTALLED_FALLBACK', 'SHORTCUT_FALLBACK', 'FINAL_AGGREGATION', ]); @@ -707,7 +1486,15 @@ describe('desktop trusted release workflow', () => { assert.ok(substages.includes(substage)); assert.ok(['BEGIN', 'COMPLETE', 'FAILED', 'SKIPPED'].includes(status)); } - for (const substage of ['MSI_UNINSTALL', 'INSTALL_TREE', 'PROTOCOL', 'SHORTCUT_FILE', 'SHORTCUT_FOLDER']) { + for (const substage of [ + 'MSI_UNINSTALL', + 'INSTALL_TREE', + 'PROTOCOL', + 'APP_PATH', + 'HKCU_INSTALLED', + 'SHORTCUT_FILE', + 'SHORTCUT_FOLDER', + ]) { for (const status of ['BEGIN', 'COMPLETE', 'FAILED']) { assert.ok(cleanupCalls.some(match => match[1] === 'UNINSTALL' && match[2] === substage && match[3] === status)); } @@ -723,6 +1510,8 @@ describe('desktop trusted release workflow', () => { 'USER', 'INSTALL_ROOT_FALLBACK', 'PROTOCOL_FALLBACK', + 'APP_PATH_FALLBACK', + 'HKCU_INSTALLED_FALLBACK', 'SHORTCUT_FALLBACK', 'FINAL_AGGREGATION', ]) { @@ -740,7 +1529,7 @@ describe('desktop trusted release workflow', () => { ); }); - test('keeps the canonical common shortcut and ownership-aware nonrecursive cleanup', () => { + test('keeps the canonical common shortcut and exact-identity cleanup', () => { const probeStart = installedWindowsAppTest.indexOf('function Test-StartMenuShortcutAsOrdinaryUser('); const probeEnd = installedWindowsAppTest.indexOf('function New-SmokeUserDataDirectory(', probeStart); assert.ok(probeStart >= 0 && probeEnd > probeStart); @@ -762,11 +1551,11 @@ describe('desktop trusted release workflow', () => { ); assert.match( installedWindowsAppTest, - /\$startMenuShortcutCreatedByRun =\n\s+!\$startMenuShortcutExistedBeforeInstall -and \(Test-Path -LiteralPath \$startMenuShortcut\)/, + /\$script:startMenuShortcutCreatedByRun =\n\s+!\$startMenuShortcutExistedBeforeInstall -and \(Test-Path -LiteralPath \$startMenuShortcut\)/, ); assert.match( installedWindowsAppTest, - /\$startMenuShortcutFolderCreatedByRun =\n\s+!\$startMenuShortcutFolderExistedBeforeInstall -and \(Test-Path -LiteralPath \$startMenuShortcutFolder\)/, + /\$script:startMenuShortcutFolderCreatedByRun =\n\s+!\$startMenuShortcutFolderExistedBeforeInstall -and[\s\S]{0,40}\(Test-Path -LiteralPath \$startMenuShortcutFolder\)/, ); const cleanupStart = installedWindowsAppTest.indexOf("Write-Stage 'CLEANUP' 'BEGIN'"); @@ -774,19 +1563,25 @@ describe('desktop trusted release workflow', () => { const cleanup = installedWindowsAppTest.slice(cleanupStart); assert.match( cleanup, - /if \(\$startMenuShortcutCreatedByRun -and \(Test-Path -LiteralPath \$startMenuShortcut\)\) \{\n\s+Remove-Item -LiteralPath \$startMenuShortcut -Force -ErrorAction Stop/, + /if \(\$startMenuShortcutCreatedByRun -and \(Test-Path -LiteralPath \$startMenuShortcut\)\)[\s\S]*Get-FileIdentity \$startMenuShortcut[\s\S]*Remove-Item -LiteralPath \$startMenuShortcut -Force -ErrorAction Stop/, ); assert.match( cleanup, - /if \(\$startMenuShortcutFolderCreatedByRun[\s\S]*\$ownedShortcutFolderContents\.Count -eq 0\) \{\n\s+Remove-Item -LiteralPath \$startMenuShortcutFolder -Force -ErrorAction Stop/, + /if \(\$startMenuShortcutFolderCreatedByRun[\s\S]*Get-DirectoryIdentity \$startMenuShortcutFolder[\s\S]*Get-ChildItem -LiteralPath \$startMenuShortcutFolder -Force[\s\S]*Remove-Item -LiteralPath \$startMenuShortcutFolder -Force -ErrorAction Stop/, ); - assert.doesNotMatch( - cleanup, - /Remove-Item -LiteralPath \$startMenuShortcut(?:Folder)?[^\n]*-Recurse/, + const installFallback = cleanup.slice( + cleanup.indexOf("'CLEANUP' 'INSTALL_ROOT_FALLBACK' 'BEGIN'"), + cleanup.indexOf("'CLEANUP' 'PROTOCOL_FALLBACK' 'BEGIN'"), + ); + const shortcutFallback = cleanup.slice( + cleanup.indexOf("'CLEANUP' 'SHORTCUT_FALLBACK' 'BEGIN'"), + cleanup.indexOf("'CLEANUP' 'FINAL_AGGREGATION' 'BEGIN'"), ); + assert.doesNotMatch(installFallback, /Remove-Item[^\n]*-Recurse/); + assert.doesNotMatch(shortcutFallback, /Remove-Item[^\n]*-Recurse/); assert.doesNotMatch( installedWindowsAppTest, - /Remove-Item[^\n]*(?:\$commonPrograms|\$startMenuShortcut(?:Folder)?)[^\n]*-Recurse|Remove-Item[^\n]*-Recurse[^\n]*(?:\$commonPrograms|\$startMenuShortcut(?:Folder)?)/, + /Remove-Item[^\n]*\$commonPrograms[^\n]*-Recurse|Remove-Item[^\n]*-Recurse[^\n]*\$commonPrograms/, ); assert.match(installedWindowsAppTest, /machine uninstall left the common Start Menu shortcut behind/); assert.match(installedWindowsAppTest, /machine uninstall left the common Start Menu folder behind/); diff --git a/docs/docs/architecture/agent-runtime.md b/docs/docs/architecture/agent-runtime.md index 1512c1949..28caab06c 100644 --- a/docs/docs/architecture/agent-runtime.md +++ b/docs/docs/architecture/agent-runtime.md @@ -146,6 +146,9 @@ Common settings: HOST_CODEX_DIR=/home/your-user/.codex CODEX_TIMEOUT_MS=86400000 CODEX_MAX_TURNS=1000 +CODEX_STREAM_TRANSPORT=websocket +CODEX_STREAM_IDLE_TIMEOUT_MS=1800000 +CODEX_STREAM_MAX_RETRIES=5 ``` The entrypoint checks for `/home/node/.codex/config.toml`, prepares `sessions` and `rules`, and avoids recursively changing bind-mounted workspace ownership. Codex runs as: @@ -154,7 +157,7 @@ The entrypoint checks for `/home/node/.codex/config.toml`, prepares `sessions` a codex exec --json --dangerously-bypass-approvals-and-sandbox --config features.multi_agent=false --skip-git-repo-check --cd /home/node/workspace - ``` -When a model is selected, ProPR adds `--model `. Codex emits NDJSON events that ProPR parses into logs, result text, session metadata, and token usage. +When a model is selected, ProPR adds `--model `. By default, ProPR selects a WebSocket-capable OpenAI provider with a 30-minute stream idle timeout so long, quiet turns are not pinned to a single HTTP response body. Set `CODEX_STREAM_TRANSPORT=sse` when WebSockets are unavailable or `CODEX_STREAM_TRANSPORT=inherit` to preserve a custom provider from the mounted Codex configuration. Codex emits NDJSON events that ProPR parses into logs, result text, session metadata, and token usage; reconnect notices remain visible without making a later successful turn fail. ### Antigravity diff --git a/docs/docs/concepts/glossary.md b/docs/docs/concepts/glossary.md index 2319c9c3c..2901aafc9 100644 --- a/docs/docs/concepts/glossary.md +++ b/docs/docs/concepts/glossary.md @@ -31,6 +31,18 @@ title: Glossary **Task** — one unit of agent work with its own record: prompt, isolated run, logs, usage, commits, and resulting PR or follow-up. +**Synthetic agent** — a provider-neutral virtual agent whose models route to configured direct agent/model members. See [Synthetic Pools](../features/synthetic-pools.md). + +**Synthetic model** — a virtual model ID exposed by a synthetic agent in normal model selectors. + +**Pool member** — one direct-agent alias and supported physical model participating in a synthetic model. + +**Priority tier** — all eligible pool members at one priority; only the highest currently eligible tier participates in selection. + +**Usage cap** — an optional session or weekly usage percentage above which a capped pool member becomes ineligible. + +**Failover** — retrying the same call and workspace on another eligible pool member after a retryable physical failure. + **Ultrafix** — the automated review-fix loop: `/review` scores the PR, fixes are applied, and cycles repeat until the target score, cycle limit, or a human stop. See [PR Comment Commands](../features/pr-commands.md#ultrafix). **Worktree** — the dedicated Git working directory each task gets, paired with its own branch and container, so parallel tasks never collide and the main checkout stays untouched. diff --git a/docs/docs/features/agents-and-models.md b/docs/docs/features/agents-and-models.md index 055d86cbd..f3b537b05 100644 --- a/docs/docs/features/agents-and-models.md +++ b/docs/docs/features/agents-and-models.md @@ -21,6 +21,8 @@ Use routing when you want to: - Fall back to another provider when rate limits or quota are tight - Preserve the same PR follow-up workflow across providers +For virtual routing across several configured direct agents, see [Synthetic Pools](./synthetic-pools.md). + ## Supported Agents | Agent | Type | Docker image | Existing host credentials | diff --git a/docs/docs/features/propr-cli.md b/docs/docs/features/propr-cli.md index a9dba717d..4828b1a9d 100644 --- a/docs/docs/features/propr-cli.md +++ b/docs/docs/features/propr-cli.md @@ -250,13 +250,18 @@ durations are integer milliseconds. ```bash propr repo list # Monitored repositories propr repo add owner/repo -a "Alias" -b dev # Add with alias and base branch +propr repo add owner/repo --auto-ci-followup # Enable automatic follow-up for failed CI propr repo remove owner/repo propr repo toggle owner/repo --enable # Enable/disable monitoring +propr repo toggle owner/repo --auto-ci-followup # Enable failed-CI follow-up +propr repo toggle owner/repo --no-auto-ci-followup # Disable failed-CI follow-up propr repo index owner/repo # Full reindex propr repo index owner/repo --incremental # Incremental reindex propr repo status # Indexing status for all repos ``` +Automatic CI follow-up is configured per repository and is **off by default**. Enable it only for repositories whose CI failures are high-quality, trusted signals; noisy or flaky checks can otherwise create unnecessary follow-up work. `propr repo list` shows the current setting for every monitored repository. + ## Agents ```bash @@ -269,12 +274,18 @@ propr agent add --file agent-config.json # From a JSON file (or `-` for stdi propr agent enable my-agent # Enable / disable without deleting propr agent disable my-agent propr agent delete my-agent --force + +propr agent pool list --json > pools.json +propr agent pool apply pools.json # Also accepts '-' for stdin +propr agent pool delete balanced-pool ``` Agent types: `claude`, `codex`, `antigravity`, `opencode`, `vibe`. See [Agents and Models](./agents-and-models.md) for the model catalog, label formats, and per-agent credential setup, including the OpenCode host-authentication steps and the `XDG_DATA_HOME` requirement for file-based OpenCode auth. +Synthetic pool commands replace one complete, nested configuration document. JSON from `pool list --json` can be passed unchanged to `pool apply`; validation failures retain the backend's nested field message. See [Synthetic Pools](./synthetic-pools.md) for schemas and routing behavior. + ## To-Dos ```bash diff --git a/docs/docs/features/synthetic-pools.md b/docs/docs/features/synthetic-pools.md new file mode 100644 index 000000000..c0284b776 --- /dev/null +++ b/docs/docs/features/synthetic-pools.md @@ -0,0 +1,114 @@ +--- +title: Synthetic Pools +--- + +# Synthetic Pools + +Synthetic pools give a stable virtual agent/model identity to a set of existing direct agent accounts. They are useful for rotating between two accounts from one provider, balancing capacity, or failing over to a different provider without changing repository, planner, review, or issue configuration. + +## Concepts + +- A **synthetic agent** is a virtual coding agent. It has an alias and one or more synthetic models but no provider credentials of its own. +- A **synthetic model** is a virtual model ID exposed in ProPR's instance catalog and model selectors. +- A **pool member** is one direct-agent alias and one physical model supported by that direct agent. A synthetic agent can never be a member of another pool. +- A **priority tier** is the set of currently eligible members with the same priority, from 0 through 100. Routing considers only the highest eligible tier. +- A **usage cap** makes a member ineligible when its current session or weekly usage reaches a configured percentage. +- **Failover** retries a synthetic call on another eligible member after a retryable physical failure. + +Synthetic choices use a neutral layers icon in the UI because the pool is not owned by a provider. Task lists keep their model column concise by showing the virtual model. Playground results, task details, task-history attempts, and LLM logs also show the physical agent/model that actually ran. + +## Configure in the Web UI + +Installation administrators can open **Coding Agents → Synthetic Pools** to create, edit, enable, disable, or delete pools. Each virtual model supports **Round robin** or **Usage based** routing, an enabled state, and one or more direct members. Each member has an enabled state, priority, and optional session and weekly maximum percentages. + +The member picker contains only configured direct agents and their supported physical models. Disabled direct agents remain visible for correcting existing configuration but are not eligible at runtime. Demo mode is read-only and disables every mutation. + +Backend validation is authoritative. A rejected save keeps the editor and unsaved values open and associates a validation message with its nested model/member field when the response contains a field path. + +### Same-provider round robin + +Create two direct Codex agents, such as `codex-account-a` and `codex-account-b`, using separate credential directories. Add both with the same physical model to one enabled virtual model, give both priority 100, and choose **Round robin**. Successful calls rotate between the two accounts using a cursor shared by the workers. + +### Usage-based selection + +**Usage based** still honors strict priority first. Within the highest eligible tier it selects the member with the most normalized headroom below its configured caps. If no caps are configured, all members have equal headroom; use round robin when deterministic rotation is the goal. + +## Primary and fallback recipe + +For cross-provider primary/fallback routing: + +1. Add the primary member at priority 100. +2. Optionally set its weekly maximum to 80%. +3. Add the fallback member at priority 0. +4. Use either strategy; strategy only chooses among members inside the selected priority tier. + +The priority-0 member is not mixed into normal traffic. It becomes eligible for selection only when every higher-priority member is disabled, capped, unavailable, too small for the call's context, or has failed during that call. This priority-100 primary plus priority-0 fallback pattern is the recommended way to reserve fallback capacity. + +## Context-aware early selection + +ProPR can select a route early so planning and task setup retain one stable physical choice. Before the first physical invocation it finalizes the required prompt plus output reserve. If the selected model's context limit is too small, ProPR reselects without counting that member as a failed attempt. + +Every later failover applies the same context requirement. A smaller-context fallback can therefore be skipped even when it is healthy: sending a prompt that cannot fit would only create a misleading provider failure. + +## Usage data and degraded pools + +A capped member requires fresh Agent Tank data whose name exactly matches the direct-agent alias. Missing, refreshing, stale, provider-wide-only, or differently named data makes that capped member ineligible. The default freshness window is five minutes and can be changed with `SYNTHETIC_USAGE_FRESHNESS_MS`. + +Uncapped pools do not require Agent Tank. If no member of a synthetic model is currently eligible, the pool reports **Degraded**. This does not mark its unrelated direct agents unhealthy; direct-agent health remains independent. + +## Failure retries and workspace preservation + +A retryable physical error fails over to the next eligible, not-yet-attempted member. Every physical attempt is recorded as a separate history entry with the virtual identity, physical agent/model, attempt number, and selection reason. These attempts remain part of one task: ProPR does not create extra tasks or extra worktrees. + +Implementation retries reuse the same task workspace and branch, so edits made before a provider failure remain available to the fallback. Explicit user cancellation, security-policy failures, invalid configuration, and prompts that exceed the context limit are not retried on another member. + +## CLI + +The CLI manages the same complete configuration document: + +```bash +propr agent pool list +propr agent pool list --json > pools.json +propr agent pool apply pools.json +cat pools.json | propr agent pool apply - +propr agent pool delete balanced-pool +propr agent pool delete balanced-pool --json +``` + +`pool list --json` emits `{ "synthetic_agents": [...] }`. That file can be passed unchanged to `pool apply`; `apply` also accepts the array itself. Full-document replacement keeps nested multi-model configuration unambiguous and makes review, backup, and automation straightforward. Backend validation messages, including nested field paths, are printed without being rewritten. + +An abbreviated two-tier document looks like this (IDs must be UUIDs): + +```json +{ + "synthetic_agents": [{ + "id": "11111111-1111-4111-8111-111111111111", + "alias": "balanced-pool", + "enabled": true, + "defaultModel": "balanced", + "models": [{ + "id": "balanced", + "displayName": "Balanced", + "enabled": true, + "strategy": "usage_based", + "members": [ + { + "id": "22222222-2222-4222-8222-222222222222", + "directAgentAlias": "codex-primary", + "model": "gpt-5.6-sol", + "enabled": true, + "priority": 100, + "usageLimits": { "weeklyMaxPercent": 80 } + }, + { + "id": "33333333-3333-4333-8333-333333333333", + "directAgentAlias": "claude-fallback", + "model": "claude-sonnet-5", + "enabled": true, + "priority": 0 + } + ] + }] + }] +} +``` diff --git a/docs/docs/features/web-ui.md b/docs/docs/features/web-ui.md index 3fc977845..831a1333b 100644 --- a/docs/docs/features/web-ui.md +++ b/docs/docs/features/web-ui.md @@ -58,6 +58,8 @@ See [Repository Knowledge](./repository-knowledge.md) and [Branch Configuration] **Coding Agents** (`/ai-agents`) is an administrator-only split view: configure agent aliases and their models on one side, and a **playground** to test an agent interactively on the other. When adding Claude, Codex, Antigravity, or OpenCode, choose a new-account login or reuse an existing config. New-account login creates an isolated ProPR-managed credential directory, so multiple accounts of the same provider can coexist without entering host paths. The login dialog starts the configured agent image, displays the CLI's authorization link and instructions, and accepts requested confirmation codes or terminal menu input without requiring the agent CLI on the host. Existing entries also include **Log in**. The dialog includes Up, Down, and Enter controls for provider and login-method menus; Escape or backdrop dismissal cancels its temporary container. Vibe uses an API key or pre-populated config instead of this interactive flow. See [Agents And Models](./agents-and-models.md). +Administrators can switch the configuration pane to **Synthetic Pools** to combine direct agent/model pairs behind virtual models with strict priority tiers, usage caps, round-robin or usage-based routing, and failover. Synthetic models also appear in the playground, which reports the virtual choice and physical member used. See [Synthetic Pools](./synthetic-pools.md). + ## LLM Log **LLM Log** (`/llm-logs`) shows every model call with expandable rows and filters by execution type, model, status, and work type. What each record contains and how to use the page for cost analysis is covered in [Metrics](../operations/metrics.md). diff --git a/docs/docs/operations/configuration-reference.md b/docs/docs/operations/configuration-reference.md index 4cb60548a..4cedce8e6 100644 --- a/docs/docs/operations/configuration-reference.md +++ b/docs/docs/operations/configuration-reference.md @@ -88,7 +88,10 @@ Unified image selection, per-agent credential paths, and execution limits. Codin | `CLAUDE_MAX_TURNS` | Shipped `10` / code falls back to `1000` if unset | Maximum agent turns per Claude run. | Optional. | | `CLAUDE_TIMEOUT_MS` | `86400000` (24 hours) | Claude task run timeout. | Optional. | | `CODEX_TIMEOUT_MS` | `86400000` (24 hours) | Codex task run timeout. | Optional. | -| `CONTEXT_ANALYSIS_TIMEOUT_MS` | `1800000` (30 minutes) | Timeout for planner keyword extraction and semantic relevance scoring calls. | Optional. | +| `CODEX_STREAM_TRANSPORT` | `websocket` | Codex response transport. `websocket` avoids long-lived HTTP response deadlines, `sse` supports environments that cannot carry WebSockets, and `inherit` leaves the mounted Codex provider configuration unchanged. | Optional; use `inherit` with a custom provider. | +| `CODEX_STREAM_IDLE_TIMEOUT_MS` | `1800000` (30 minutes) | Maximum quiet period on a Codex response stream before reconnecting. This is separate from the whole-task `CODEX_TIMEOUT_MS`. | Optional tuning. | +| `CODEX_STREAM_MAX_RETRIES` | `5` | Number of Codex response-stream reconnect attempts. Zero disables retries. | Optional tuning. | +| `CONTEXT_ANALYSIS_TIMEOUT_MS` | `3600000` (60 minutes) | Timeout for planner keyword extraction and semantic relevance scoring calls. | Optional. | | `ANTIGRAVITY_TIMEOUT_MS` | `86400000` (24 hours) | Antigravity task run timeout. | Optional. | | `OPENCODE_TIMEOUT_MS` | `86400000` (24 hours) | OpenCode task run timeout. | Optional. | | `VIBE_MAX_TURNS` | `1000` | Maximum agent turns per Vibe run. | Optional. | diff --git a/docs/docs/operations/desktop-pairing.md b/docs/docs/operations/desktop-pairing.md index c33031cfa..baafeac4c 100644 --- a/docs/docs/operations/desktop-pairing.md +++ b/docs/docs/operations/desktop-pairing.md @@ -39,8 +39,10 @@ canonical SemVer; both compatibility values are canonical `YYYY-MM-DD` versions; the identity is an exact lowercase UUIDv4; and the endpoint is either `null` during restart/configuration or the bare canonical `https://t-.propr.dev` origin. Every capability key is required and every -capability value is a JSON boolean. Missing, extra, coerced, malformed, or -non-canonical fields are incompatible discovery, never partial readiness. +capability value is a JSON boolean. Missing, extra, duplicate, oversized, +coerced, malformed, or non-canonical fields are incompatible discovery, never +partial readiness. Native and shared-client consumers use the same bounded wire +parser. The public identity is not a credential. It is randomly created in the stack's private durable `data/` directory and is shared by the host CLI and root-running @@ -59,9 +61,10 @@ discovery and identity contract. ## Pairing sequence -1. The trusted desktop process sends `POST /api/desktop/pairings` with - `{"clientName":"Alice's MacBook"}`. `clientName` is printable text from 1 - through 80 characters. +1. The trusted desktop process repeats strict unauthenticated discovery at the + exact candidate origin. It then sends `POST /api/desktop/pairings` with the + client name and its main-owned profile/origin/scope/credential-generation + binding. `clientName` is printable text from 1 through 80 characters. 2. A `201` response contains `pairingId`, `deviceSecret`, `approvalUrl`, `expiresAt`, and `interval` (seconds). Both identifiers have at least 128 bits of entropy; the device secret has 256 bits. Store the secret only in trusted @@ -102,7 +105,15 @@ Keychain, Windows Credential Manager, or Linux Secret Service. Never put it in `localStorage`, IndexedDB, renderer state, a pairing URL, logs, crash reports, or analytics. Keep the instance origin with the credential and refuse to send it to another origin. Treat TLS certificate failures as terminal; HTTP is accepted -only for loopback development. +only for loopback development. Persist the discovery `publicInstanceIdentity` +with the encrypted credential and bind it atomically to the profile ID, +canonical origin, and credential generation. Before a stored token is used +after launch, reconnect, profile switch, or tunnel rotation, repeat +unauthenticated strict discovery at that exact origin. An absent, malformed, or +different identity produces no bearer-, cookie-, or socket-authenticated +request, durably detaches the old credential, and requires a new pairing +generation. Legacy credentials without this binding fail closed and are removed +locally during migration. The server stores SHA-256 token and device-secret hashes, never plaintext. Token rows retain the owner GitHub ID/profile snapshot, creation and last-use times, diff --git a/docs/docs/operations/hosted-ui-tunnel.md b/docs/docs/operations/hosted-ui-tunnel.md index b88046a73..ae81fce1f 100644 --- a/docs/docs/operations/hosted-ui-tunnel.md +++ b/docs/docs/operations/hosted-ui-tunnel.md @@ -46,7 +46,7 @@ The hosted PWA's manifest, service worker, installation, notification permission ### Compatibility check -Before the hosted UI starts its normal auth/session checks, it calls the public `/api/compatibility` endpoint on the selected API origin. Desktop discovery uses the separately bounded, rate-limited, cache-disabled `/api/desktop/discovery` response. That response adds only the canonical managed endpoint and the stack's random public installation identity to version/capability metadata; it contains no credential or account state. If the hosted UI cannot support the compatibility contract, it stops at a clear version-mismatch screen instead of running against incompatible endpoints or Socket.IO events. `/api/status` includes the same version metadata for authenticated diagnostics. +Before the hosted UI starts its normal auth/session checks, it calls the public `/api/compatibility` endpoint on the selected API origin. Desktop discovery uses the separately bounded, rate-limited, cache-disabled `/api/desktop/discovery` response. That response adds only the canonical managed endpoint and the stack's random public installation identity to version/capability metadata; it contains no credential or account state. Desktop main preserves that identity through Connect confirmation and encrypted profile persistence, then revalidates it without credentials before stored REST or Socket.IO authentication. Tunnel endpoint or identity rotation therefore creates a fresh pairing generation; no prior-origin credential, socket, or cookie state is carried across. If the hosted UI cannot support the compatibility contract, it stops at a clear version-mismatch screen instead of running against incompatible endpoints or Socket.IO events. `/api/status` includes the same version metadata for authenticated diagnostics. Only a **definitive** mismatch (the API reports a contract the UI knows it is too old or too new for) hard-blocks. A v1 rollout exception applies when the metadata is simply *absent* — an older API that predates `/api/compatibility` (returns 404) or returns no contract: the UI logs a console warning and continues, so an otherwise-working stack is never trapped mid-upgrade. This soft-warning fallback is temporary; once publishing the compatibility contract is a baseline expectation, missing metadata is intended to become a hard block like any other mismatch. diff --git a/docs/sidebars.ts b/docs/sidebars.ts index 5a2ca320c..323a36a28 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -82,6 +82,7 @@ const sidebars: SidebarsConfig = { label: 'Reference', items: [ 'features/agents-and-models', + 'features/synthetic-pools', 'features/propr-cli', ], }, diff --git a/package-lock.json b/package-lock.json index fad20a9ef..f026bc19c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6150,9 +6150,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.418", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.418.tgz", - "integrity": "sha512-UzS26r3AEbG5wSoGVpJKqwHIU9zwQN7LHdVIThDrJpS0I5KdlXFMEb8543fhc9dVnIIAST6ar8rhwa00AL5MlA==", + "version": "1.5.420", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz", + "integrity": "sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==", "dev": true, "license": "ISC" }, @@ -9177,280 +9177,6 @@ "node": ">= 0.8.0" } }, - "node_modules/lightningcss": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", - "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", - "dev": true, - "license": "MPL-2.0", - "optional": true, - "peer": true, - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.33.0", - "lightningcss-darwin-arm64": "1.33.0", - "lightningcss-darwin-x64": "1.33.0", - "lightningcss-freebsd-x64": "1.33.0", - "lightningcss-linux-arm-gnueabihf": "1.33.0", - "lightningcss-linux-arm64-gnu": "1.33.0", - "lightningcss-linux-arm64-musl": "1.33.0", - "lightningcss-linux-x64-gnu": "1.33.0", - "lightningcss-linux-x64-musl": "1.33.0", - "lightningcss-win32-arm64-msvc": "1.33.0", - "lightningcss-win32-x64-msvc": "1.33.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", - "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", - "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", - "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", - "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", - "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", - "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", - "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", - "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", - "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", - "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", - "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/lilconfig": { "version": "3.1.3", "dev": true, @@ -11790,7 +11516,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.1.2", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -15279,6 +15007,9 @@ "packages/shared": { "name": "@propr/shared", "version": "0.8.15", + "dependencies": { + "zod": "^4.4.3" + }, "devDependencies": { "typescript": "^5.9.3" } diff --git a/package.json b/package.json index 4872ae6a5..cd4dbcacc 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "test:notifications:ui": "npm --workspace propr-ui test -- src/api/notificationApi.test.ts src/serviceWorker.test.ts src/serviceWorkerRegistration.test.ts src/hooks/useBrowserPush.test.tsx src/pages/SettingsPage/NotificationSettingsSection.test.tsx src/pages/InboxPage.test.tsx src/pages/inboxUtils.test.ts src/components/Inbox/NotificationActions.test.tsx src/components/MobileBottomNavigation.test.tsx src/contexts/NotificationCenterContext.test.tsx src/utils/notificationIntents.test.ts src/pages/PlanStudioPage.notificationIntent.test.tsx src/components/TaskPlanner/PlanEditor.notificationIntent.test.tsx src/components/TaskPlanner/PlanIssuesManager.notificationIntent.test.tsx src/components/TaskPlanner/PlanEditor.responsive.test.tsx", "test:notifications": "npm run build -w @propr/shared && npm run build -w @propr/core && npm run test:notifications:server && npm run test:notifications:ui", "pretest:unit": "npm run build -w @propr/shared && npm run build -w @propr/local-setup", - "test:unit": "NODE_ENV=test npx tsx --experimental-test-module-mocks --test test/minimal.test.ts test/modelName.test.ts test/agentContainerResources.test.ts test/agentDockerfileSupplyChain.test.ts test/daemonEventIntake.test.ts test/databaseMigrationGate.test.ts test/generateContext.test.ts test/githubEventIntakeMode.test.ts test/intakeModePrerequisites.test.ts test/orchestratorMigrationPhase.test.mjs test/validateRoutingUrl.test.ts test/routingWebSocketProtocol.test.ts test/routingWebSocketIntakeService.test.ts test/routingStatusPublisher.test.ts test/releaseValidation.test.mjs test/sessionSecret.test.ts test/testSuiteRunner.test.mjs packages/api/test/connectAuth.test.ts packages/api/test/attachmentUploadCleanup.test.ts packages/api/test/configReloadSubscription.test.ts packages/api/test/desktopApiBoundary.test.ts packages/api/test/dockerCommandSafety.test.ts packages/api/test/listenAddress.test.ts packages/api/test/oauthState.test.ts packages/api/test/requestRateLimits.test.ts packages/api/test/statusRoutes.test.ts packages/api/test/agentRuntimeRoutes.test.ts packages/api/test/instanceAuthorization.test.ts packages/api/test/routeAuthorization.test.ts", + "test:unit": "NODE_ENV=test npx tsx --experimental-test-module-mocks --test test/minimal.test.ts test/modelName.test.ts test/agentContainerResources.test.ts test/agentDockerfileSupplyChain.test.ts test/daemonEventIntake.test.ts test/databaseMigrationGate.test.ts test/deployPrPreview.test.mjs test/generateContext.test.ts test/githubEventIntakeMode.test.ts test/intakeModePrerequisites.test.ts test/orchestratorMigrationPhase.test.mjs test/validateRoutingUrl.test.ts test/routingWebSocketProtocol.test.ts test/routingWebSocketIntakeService.test.ts test/routingStatusPublisher.test.ts test/releaseValidation.test.mjs test/sessionSecret.test.ts test/testSuiteRunner.test.mjs packages/api/test/connectAuth.test.ts packages/api/test/attachmentUploadCleanup.test.ts packages/api/test/configReloadSubscription.test.ts packages/api/test/desktopApiBoundary.test.ts packages/api/test/dockerCommandSafety.test.ts packages/api/test/listenAddress.test.ts packages/api/test/oauthState.test.ts packages/api/test/requestRateLimits.test.ts packages/api/test/statusRoutes.test.ts packages/api/test/agentRuntimeRoutes.test.ts packages/api/test/instanceAuthorization.test.ts packages/api/test/routeAuthorization.test.ts", "test:e2e": "npx tsx --test test/e2e.test.ts", "test:docker": "docker-compose run --rm -e REDIS_HOST=redis -e NODE_ENV=test worker npx tsx --test test/*.test.ts", "test:docker:single": "docker-compose run --rm -e REDIS_HOST=redis -e NODE_ENV=test worker npx tsx --test", diff --git a/packages/api/README.md b/packages/api/README.md index e253cec97..bc5c5c2e3 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -122,6 +122,7 @@ is intentionally running in the same isolated local-development mode. - `GET /api/notifications/config` - Canonical capability route; return Web Push availability and the VAPID public key. The private key is never serialized. `/api/notifications/capabilities` is a compatibility alias. - `GET /api/notifications/preferences` - Return the complete category and quiet-hour snapshot. - `PATCH /api/notifications/preferences` - Apply a sparse update; omitted categories and channel values remain unchanged. +- `POST /api/notifications/dismiss-all` - Dismiss every active Inbox notification for the authenticated user without deleting audit events. - `GET /api/notifications/push-subscriptions` - List the authenticated user's active browser subscriptions without encryption keys. - `POST /api/notifications/push-subscriptions` - Create or refresh the authenticated user's browser subscription by endpoint. - `DELETE /api/notifications/push-subscriptions` - Revoke the authenticated user's subscription. Supply `endpoint` only in the JSON body; capability URLs are never accepted in query strings. diff --git a/packages/api/permissionGuards.ts b/packages/api/permissionGuards.ts index d5bcac6af..cf426383f 100644 --- a/packages/api/permissionGuards.ts +++ b/packages/api/permissionGuards.ts @@ -1,6 +1,19 @@ +import type { RequestHandler } from 'express'; import { requirePermission } from './authorization.js'; export const requireManageSettings = requirePermission('instance.manage_settings'); export const requireManageAgents = requirePermission('instance.manage_agents'); export const requireManageMembers = requirePermission('instance.manage_members'); export const requireManageRuntime = requirePermission('instance.manage_runtime'); + +/** + * Agent Tank's demo feed contains synthetic data and is safe for the read-only + * demo user. Real installations still require the agent-management permission. + */ +export const requireAgentTankUsageAccess: RequestHandler = (req, res, next) => { + if (req.authorization?.source === 'demo') { + next(); + return; + } + requireManageAgents(req, res, next); +}; diff --git a/packages/api/routeRegistry.ts b/packages/api/routeRegistry.ts index d48850d79..a9c52b05b 100644 --- a/packages/api/routeRegistry.ts +++ b/packages/api/routeRegistry.ts @@ -8,6 +8,7 @@ import type { createInstanceCatalogRoutes, } from './routes/index.js'; import { + requireAgentTankUsageAccess, requireManageAgents, requireManageMembers, requireManageRuntime, @@ -55,6 +56,8 @@ export function createManagementRouteEntries({ ['post', '/api/config/primary-processing-labels', requireManageSettings, configRoutes.postPrimaryProcessingLabels], ['get', '/api/config/agents', requireManageAgents, configRoutes.getAgents], ['post', '/api/config/agents', requireManageAgents, configRoutes.postAgents], + ['get', '/api/config/synthetic-agents', requireManageAgents, configRoutes.getSyntheticAgents], + ['post', '/api/config/synthetic-agents', requireManageAgents, configRoutes.postSyntheticAgents], ['get', '/api/config/summarization', requireManageSettings, configRoutes.getSummarizationSettings], ['post', '/api/config/summarization', requireManageSettings, configRoutes.postSummarizationSettings], ['get', '/api/config/repos/indexing-status', requireManageSettings, configRoutes.getRepositoriesIndexingStatus], @@ -64,7 +67,7 @@ export function createManagementRouteEntries({ ['get', '/api/config/agent-tank', requireManageAgents, configRoutes.getAgentTankSettings], ['post', '/api/config/agent-tank', requireManageAgents, configRoutes.postAgentTankSettings], ['get', '/api/config/agent-tank/status', requireManageAgents, configRoutes.getAgentTankStatus], - ['get', '/api/config/agent-tank/usage', requireManageAgents, configRoutes.getAgentTankUsage], + ['get', '/api/config/agent-tank/usage', requireAgentTankUsageAccess, configRoutes.getAgentTankUsage], ['post', '/api/config/agent-tank/refresh', requireManageAgents, configRoutes.postAgentTankRefresh], ['get', '/api/config/agent-tank/detect', requireManageAgents, configRoutes.getAgentTankDetect], @@ -100,7 +103,8 @@ export function createMemberCatalogRouteEntries({ instanceCatalogRoutes, }: MemberCatalogRouteDeps): RouteEntry[] { return [ - ['get', '/api/catalog', instanceCatalogRoutes.getCatalog], + ['get', '/api/catalog', instanceCatalogRoutes.getLegacyCatalog], + ['get', '/api/instance/catalog', instanceCatalogRoutes.getCatalog], ['get', '/api/repositories/indexing-status', instanceCatalogRoutes.getRepositoryIndexingStatus], ]; } diff --git a/packages/api/routes/agentRoutes.ts b/packages/api/routes/agentRoutes.ts index 394d1463a..af0033a7b 100644 --- a/packages/api/routes/agentRoutes.ts +++ b/packages/api/routes/agentRoutes.ts @@ -11,6 +11,7 @@ import { toProprOpenCodeModelId, type Agent, type AgentRegistry, + SyntheticAgent, } from '@propr/core'; import { AGENT_DEFAULTS, isManagedAgentConfigPath } from '@propr/shared'; import { requireManageAgents } from '../permissionGuards.js'; @@ -19,6 +20,7 @@ const execFileAsync = promisify(execFile); interface AgentChatQuery { agentId: string; + syntheticConfigId?: string; model?: string; } @@ -35,6 +37,31 @@ interface AgentChatResult { response?: string; error?: string; durationMs: number; + syntheticConfigId?: string; + virtualAgentAlias?: string; + virtualModel?: string; + physicalAgentAlias?: string; + physicalModel?: string; + attemptNumber?: number; +} + +interface ChatRoutingMetadata { + virtualAgentAlias?: string; + virtualModel?: string; + physicalAgentAlias?: string; + physicalModel?: string; + attemptNumber?: number; +} + +function chatRoutingFields(metadata: Record | undefined): ChatRoutingMetadata { + if (!metadata) return {}; + return { + virtualAgentAlias: typeof metadata.virtualAgentAlias === 'string' ? metadata.virtualAgentAlias : undefined, + virtualModel: typeof metadata.virtualModel === 'string' ? metadata.virtualModel : undefined, + physicalAgentAlias: typeof metadata.physicalAgentAlias === 'string' ? metadata.physicalAgentAlias : undefined, + physicalModel: typeof metadata.physicalModel === 'string' ? metadata.physicalModel : undefined, + attemptNumber: typeof metadata.attemptNumber === 'number' ? metadata.attemptNumber : undefined, + }; } function resolveHostPath(configPath: string): string { @@ -130,6 +157,58 @@ function canonicalChatModel(agent: Agent, model: string | undefined): string { : fallbackModel; } +async function executeChatQuery( + registry: AgentRegistry, + query: AgentChatQuery, + prompt: string, + context: string | undefined, +): Promise { + const requestedAgentId = query.syntheticConfigId || query.agentId; + const agent = await resolveChatAgent(registry, requestedAgentId); + + if (!agent) { + return { + agentId: requestedAgentId, + model: query.model || 'default', + error: 'Agent not found', + durationMs: 0, + }; + } + + const start = Date.now(); + const routingSession = agent instanceof SyntheticAgent + ? agent.beginRoutingSession(query.model) + : undefined; + + try { + const analysisResult = routingSession + ? await routingSession.analyze(prompt, { context, model: query.model }) + : await agent.analyze(prompt, { context, model: query.model }); + const routing = chatRoutingFields(routingSession?.routingMetadata); + return { + agentId: requestedAgentId, + ...(query.syntheticConfigId ? { syntheticConfigId: query.syntheticConfigId } : {}), + agentAlias: agent.config.alias, + model: routing.virtualModel || canonicalChatModel(agent, analysisResult.modelUsed || query.model), + ...routing, + response: analysisResult.response, + error: analysisResult.success === false ? (analysisResult.error || 'Analysis failed') : undefined, + durationMs: Date.now() - start, + }; + } catch (error) { + const routing = chatRoutingFields(routingSession?.routingMetadata); + return { + agentId: requestedAgentId, + ...(query.syntheticConfigId ? { syntheticConfigId: query.syntheticConfigId } : {}), + agentAlias: agent.config.alias, + model: routing.virtualModel || canonicalChatModel(agent, query.model), + ...routing, + error: (error as Error).message, + durationMs: Date.now() - start, + }; + } +} + export function createAgentRoutes() { const router = Router(); @@ -171,38 +250,7 @@ export function createAgentRoutes() { // use the same agent credentials concurrently. const results: AgentChatResult[] = []; for (const query of queries) { - const agent = await resolveChatAgent(registry, query.agentId); - - if (!agent) { - results.push({ - agentId: query.agentId, - model: query.model || 'default', - error: 'Agent not found', - durationMs: 0 - }); - continue; - } - - const start = Date.now(); - try { - const analysisResult = await agent.analyze(prompt, { context, model: query.model }); - results.push({ - agentId: query.agentId, - agentAlias: agent.config.alias, - model: canonicalChatModel(agent, analysisResult.modelUsed || query.model), - response: analysisResult.response, - error: analysisResult.success === false ? (analysisResult.error || 'Analysis failed') : undefined, - durationMs: Date.now() - start - }); - } catch (err) { - results.push({ - agentId: query.agentId, - agentAlias: agent.config.alias, - model: canonicalChatModel(agent, query.model), - error: (err as Error).message, - durationMs: Date.now() - start - }); - } + results.push(await executeChatQuery(registry, query, prompt, context)); } res.json({ results }); diff --git a/packages/api/routes/configRepoValidation.ts b/packages/api/routes/configRepoValidation.ts index ac4005434..bb19561c2 100644 --- a/packages/api/routes/configRepoValidation.ts +++ b/packages/api/routes/configRepoValidation.ts @@ -41,6 +41,23 @@ export function isValidRepoName(value: string): boolean { return /^[a-zA-Z0-9\-_]+\/[a-zA-Z0-9\-_.]+$/.test(value); } +export function withDefaultRepoAutoFollowup(repo: RepoToMonitor): RepoToMonitor { + return { ...repo, autoFollowupOnFailedCi: repo.autoFollowupOnFailedCi === true }; +} + +export function preserveRepoAutoFollowup( + previousRepos: RepoToMonitor[], + normalizedRepos: RepoToMonitor[], + incomingRepos: unknown[] +): RepoToMonitor[] { + return normalizedRepos.map((repo, index) => { + const incomingRepo = incomingRepos[index] as Partial; + if (incomingRepo.autoFollowupOnFailedCi !== undefined) return repo; + const previousRepo = previousRepos.find(candidate => candidate.id === repo.id); + return { ...repo, autoFollowupOnFailedCi: previousRepo?.autoFollowupOnFailedCi === true }; + }); +} + export function normalizeRepoConfig(repo: unknown): ValidationResult { const candidateResult = parseRepoObject(repo); if (!candidateResult.ok) return candidateResult; @@ -58,11 +75,15 @@ export function normalizeRepoConfig(repo: unknown): ValidationResult configManager.AgentRegistry.getInstance().refresh(), + }, + ); const createJsonPostHandler = ({ lockKey, pickValue, validate, save, subtype, body, committedErrorMessage, activity }: JsonPostHandlerConfig) => async (req: Request, res: Response): Promise => { const bodyValidation = validateJsonObjectBody(req.body); if (!bodyValidation.ok) { @@ -173,15 +183,12 @@ export function createConfigRoutes(deps: ConfigRoutesDeps) { const getFollowupIgnoreKeywords = createJsonGetHandler(() => configStore.loadFollowupIgnoreKeywords(), followup_ignore_keywords => ({ followup_ignore_keywords }), 'Failed to load followup ignore keywords', '/api/config/followup-ignore-keywords GET'); const postFollowupIgnoreKeywords = createJsonPostHandler({ lockKey: 'config:ignore-keywords:lock', pickValue: body => body.followup_ignore_keywords, validate: followup_ignore_keywords => parseNormalizedStringArrayResult(followup_ignore_keywords, 'followup_ignore_keywords'), save: followup_ignore_keywords => configStore.saveFollowupIgnoreKeywords(followup_ignore_keywords), subtype: 'followup_ignore_keywords_update', body: followup_ignore_keywords => ({ followup_ignore_keywords }), committedErrorMessage: 'Follow-up ignore keywords were saved, but publishing the config update notification failed. Persisted config may require a follow-up check.' }); - async function getRepos(_req: Request, res: Response): Promise { - try { - const repos = await configStore.loadMonitoredReposRaw(); - res.json({ repos_to_monitor: repos }); - } catch (error) { - console.error('Error in /api/config/repos GET:', error); - res.status(500).json({ error: 'Failed to load repository configuration' }); - } - } + const getRepos = createJsonGetHandler( + async () => (await configStore.loadMonitoredReposRaw()).map(withDefaultRepoAutoFollowup), + repos_to_monitor => ({ repos_to_monitor }), + 'Failed to load repository configuration', + '/api/config/repos GET' + ); async function postRepos(req: Request, res: Response): Promise { const bodyValidation = validateJsonObjectBody(req.body); @@ -196,17 +203,18 @@ export function createConfigRoutes(deps: ConfigRoutesDeps) { return; } // Validate and process repos before taking the lock to avoid blocking valid updates on malformed requests. - const processedRepos: RepoToMonitor[] = []; + const validatedRepos: RepoToMonitor[] = []; for (const repo of repos_to_monitor) { const normalized = normalizeRepoConfig(repo); if (!normalized.ok) { res.status(400).json({ error: normalized.error }); return; } - processedRepos.push(normalized.value); + validatedRepos.push(normalized.value); } const result = await withConfigLock(redisClient, 'config:repos:lock', async lock => { const previousRepos = await configStore.loadMonitoredReposRaw(); + const processedRepos = preserveRepoAutoFollowup(previousRepos, validatedRepos, repos_to_monitor); return saveThenPublishConfigUpdate({ save: async () => { await database.transaction(async trx => { @@ -231,7 +239,7 @@ export function createConfigRoutes(deps: ConfigRoutesDeps) { }); if (result.status === 200) { try { - await logActivityHelper(`Updated monitored repositories list (${processedRepos.length} repos)`, 'config-update', 'config_updated', req.user?.username); + await logActivityHelper(`Updated monitored repositories list (${validatedRepos.length} repos)`, 'config-update', 'config_updated', req.user?.username); } catch (error) { console.error('Failed to log monitored repositories update activity:', error); } } res.status(result.status).json(result.body); @@ -313,10 +321,14 @@ export function createConfigRoutes(deps: ConfigRoutesDeps) { return; } } + if (typeof settingsValidation.value.default_agent_alias === 'string') { + settingsValidation.value.default_agent_alias = settingsValidation.value.default_agent_alias.trim(); + } - const result = await withConfigLock(redisClient, SETTINGS_CONFIG_LOCK_KEY, async lock => - saveSettingsWithRollback({ settings: settingsValidation.value, publishConfigUpdate, configStore, database, lock }) - ); + const result = await withConfigLock(redisClient, SETTINGS_CONFIG_LOCK_KEY, async lock => { + await validateDefaultAgentSetting(settingsValidation.value, configStore); + return saveSettingsWithRollback({ settings: settingsValidation.value, publishConfigUpdate, configStore, database, lock }); + }); if (result.status === 200 && result.body.noop !== true) { try { const updatedKeys = Object.keys(settingsValidation.value); @@ -396,7 +408,9 @@ export function createConfigRoutes(deps: ConfigRoutesDeps) { return { getFollowupKeywords, postFollowupKeywords, getFollowupIgnoreKeywords, postFollowupIgnoreKeywords, getRepos, postRepos, getSettings, postSettings, getPrLabel, postPrLabel, getAiPrimaryTag, postAiPrimaryTag, getPrimaryProcessingLabels, postPrimaryProcessingLabels, - getAgents: agentsRoutes.getAgents, postAgents: agentsRoutes.postAgents, getSummarizationSettings, + getAgents: agentsRoutes.getAgents, postAgents: agentsRoutes.postAgents, getSyntheticAgents: syntheticAgentRoutes.getSyntheticAgents, + postSyntheticAgents: syntheticAgentRoutes.postSyntheticAgents, + getSummarizationSettings, postSummarizationSettings: indexingRoutes.postSummarizationSettings, getRepositoriesIndexingStatus: indexingRoutes.getRepositoriesIndexingStatus, triggerIndexing: indexingRoutes.triggerIndexing, triggerReindexAll: indexingRoutes.triggerReindexAll, stopIndexing: indexingRoutes.stopIndexing, getAgentTankSettings: agentTankRoutes.getAgentTankSettings, postAgentTankSettings: agentTankRoutes.postAgentTankSettings, diff --git a/packages/api/routes/configRoutesAgentDefaults.ts b/packages/api/routes/configRoutesAgentDefaults.ts new file mode 100644 index 000000000..69fcba8da --- /dev/null +++ b/packages/api/routes/configRoutesAgentDefaults.ts @@ -0,0 +1,26 @@ +import type * as configManager from '@propr/core'; +import { validateExecutableSyntheticDefault } from '@propr/shared'; +import { ConfigRouteError } from './configHelpers.js'; + +type DefaultAgentConfigStore = Pick< + typeof configManager, + 'loadSettings' | 'loadSyntheticAgents' | 'loadAgents' +>; + +export async function validateDefaultAgentSetting( + settings: Record, + configStore: DefaultAgentConfigStore, +): Promise { + const [currentSettings, syntheticAgents, directAgents] = await Promise.all([ + configStore.loadSettings(), + configStore.loadSyntheticAgents(), + configStore.loadAgents(), + ]); + const effectiveDefault = typeof settings.default_agent_alias === 'string' + ? settings.default_agent_alias + : typeof (currentSettings as Record).default_agent_alias === 'string' + ? ((currentSettings as Record).default_agent_alias as string).trim() + : ''; + const defaultError = validateExecutableSyntheticDefault(effectiveDefault, syntheticAgents, directAgents); + if (defaultError) throw new ConfigRouteError(409, { error: defaultError }); +} diff --git a/packages/api/routes/configRoutesAgents.ts b/packages/api/routes/configRoutesAgents.ts index 8a6dd6fcd..c4a7eca5f 100644 --- a/packages/api/routes/configRoutesAgents.ts +++ b/packages/api/routes/configRoutesAgents.ts @@ -4,12 +4,45 @@ import * as configManager from '@propr/core'; import { AgentRegistry } from '@propr/core'; import type { AgentConfig } from '@propr/core'; import type { Knex } from 'knex'; +import { + findSyntheticReferencesToDirectAgent, + validateSyntheticAgentReferences, + validateExecutableSyntheticDefault, + type SyntheticAgentConfig, +} from '@propr/shared'; import { withConfigLock, SETTINGS_CONFIG_LOCK_KEY, upsertConfigValue, buildMergedSettings, stripSpecializedSettings, loadPersistedSettingsRecord, type ConfigLockContext } from './configHelpers.js'; import type { AgentConfigStore, AgentRegistrySync, AgentsRoutesDeps, ApplyAgentsUpdateParams, ApplyAgentsUpdateResult, PersistAgentConfigurationResult, PublishAgentUpdatesParams, RollbackAgentConfigStateParams } from './configRoutesAgentsTypes.js'; import { DEFAULT_PREPARATION_DEPS, loadProcessedAgents, prepareAgentsUpdate, resolveDefaultAgentAlias } from './configRoutesAgentsPreparation.js'; +export { validateDefaultAgentSetting } from './configRoutesAgentDefaults.js'; function buildAgentPreparationError(error: string, code?: string): { code?: string; error: string } { return code ? { code, error } : { error }; } +function validateDirectAgentUpdateIntegrity( + previousAgents: AgentConfig[], + processedAgents: AgentConfig[], + syntheticAgents: SyntheticAgentConfig[], +): ApplyAgentsUpdateResult | undefined { + const proposedAliases = new Set(processedAgents.map(agent => agent.alias)); + const removalConflicts = previousAgents.flatMap(agent => { + if (proposedAliases.has(agent.alias)) return []; + const references = findSyntheticReferencesToDirectAgent(syntheticAgents, agent.alias); + return references.length > 0 ? [{ alias: agent.alias, references }] : []; + }); + if (removalConflicts.length > 0) { + const details = removalConflicts + .map(conflict => `Direct agent '${conflict.alias}' is referenced by ${conflict.references.join(', ')}`) + .join('; '); + return { + status: 409, + body: { error: `${details}. Remove those synthetic pool members before deleting the direct agent.` }, + }; + } + + const referenceValidation = validateSyntheticAgentReferences(syntheticAgents, processedAgents); + return referenceValidation.errors.length > 0 + ? { status: 400, body: { error: referenceValidation.errors.join('; ') } } + : undefined; +} async function rollbackAgentConfigState({ configStore, registry, @@ -157,6 +190,53 @@ async function publishAgentUpdates({ console.error('Failed to log agents configuration update activity:', error); } } +async function loadReasoningLevelWarnings( + configStore: AgentConfigStore, + agents: AgentConfig[], +): Promise { + if (!configStore.loadModelReasoningLevel) return []; + try { + return configManager.findReasoningLevelCliVersionWarnings( + agents, + await configStore.loadModelReasoningLevel(), + ); + } catch (warningError) { + console.warn('Could not evaluate reasoning-level CLI compatibility after agents save:', warningError); + return []; + } +} +function resolveUpdatedDefaultAgent( + processedAgents: AgentConfig[], + syntheticAgents: SyntheticAgentConfig[], + currentDefault: string | undefined, +): string | undefined { + return syntheticAgents.some(agent => agent.enabled && agent.alias === currentDefault) + ? currentDefault + : resolveDefaultAgentAlias(processedAgents, currentDefault); +} +async function loadSyntheticAgents(configStore: AgentConfigStore): Promise { + return configStore.loadSyntheticAgents ? configStore.loadSyntheticAgents() : []; +} +async function resolveAgentUpdateDefaults( + configStore: AgentConfigStore, + processedAgents: AgentConfig[], + syntheticAgents: SyntheticAgentConfig[], +): Promise { + const settings = await configStore.loadSettings(); + const currentDefault = (settings as Record).default_agent_alias as string | undefined; + const defaultError = validateExecutableSyntheticDefault( + currentDefault?.trim() || '', + syntheticAgents, + processedAgents, + ); + if (defaultError) return { status: 409, body: { error: defaultError } }; + const newDefault = resolveUpdatedDefaultAgent(processedAgents, syntheticAgents, currentDefault); + return { currentDefault, newDefault, defaultChanged: newDefault !== currentDefault }; +} export async function applyAgentsUpdate({ agents, processedAgents: providedProcessedAgents, @@ -183,10 +263,12 @@ export async function applyAgentsUpdate({ } const previousAgents = await configStore.loadAgents(); - const settings = await configStore.loadSettings(); - const currentDefault = ((settings as Record).default_agent_alias as string | undefined) ?? undefined; - const newDefault = resolveDefaultAgentAlias(processedAgents, currentDefault); - const defaultChanged = newDefault !== currentDefault; + const syntheticAgents = await loadSyntheticAgents(configStore); + const integrityError = validateDirectAgentUpdateIntegrity(previousAgents, processedAgents, syntheticAgents); + if (integrityError) return integrityError; + const defaults = await resolveAgentUpdateDefaults(configStore, processedAgents, syntheticAgents); + if ('status' in defaults) return defaults; + const { currentDefault, newDefault, defaultChanged } = defaults; try { const { settingsWereUpdated } = await persistAgentConfigurationAtomically({ @@ -244,17 +326,7 @@ export async function applyAgentsUpdate({ return publishResult; } - let warnings: string[] = []; - if (configStore.loadModelReasoningLevel) { - try { - warnings = configManager.findReasoningLevelCliVersionWarnings( - processedAgents, - await configStore.loadModelReasoningLevel() - ); - } catch (warningError) { - console.warn('Could not evaluate reasoning-level CLI compatibility after agents save:', warningError); - } - } + const warnings = await loadReasoningLevelWarnings(configStore, processedAgents); return { status: 200, diff --git a/packages/api/routes/configRoutesAgentsPreparation.ts b/packages/api/routes/configRoutesAgentsPreparation.ts index 66125219d..c29896257 100644 --- a/packages/api/routes/configRoutesAgentsPreparation.ts +++ b/packages/api/routes/configRoutesAgentsPreparation.ts @@ -21,13 +21,13 @@ export const DEFAULT_PREPARATION_DEPS: AgentPreparationDeps = { export function resolveDefaultAgentAlias( processedAgents: AgentConfig[], currentDefault: string | undefined, + additionalEnabledAliases: Iterable = [], ): string | undefined { const enabledAgents = processedAgents.filter(agent => agent.enabled); - if (enabledAgents.length === 0) return undefined; - if (!currentDefault || !enabledAgents.some(agent => agent.alias === currentDefault)) { - return enabledAgents[0].alias; - } - return currentDefault; + const enabledAliases = new Set(enabledAgents.map(agent => agent.alias)); + for (const alias of additionalEnabledAliases) enabledAliases.add(alias); + if (currentDefault && enabledAliases.has(currentDefault)) return currentDefault; + return enabledAgents[0]?.alias; } function requiresExplicitVersionSpec(versionType: CliVersionType): boolean { diff --git a/packages/api/routes/configRoutesAgentsTypes.ts b/packages/api/routes/configRoutesAgentsTypes.ts index 02e600ab2..721f190cb 100644 --- a/packages/api/routes/configRoutesAgentsTypes.ts +++ b/packages/api/routes/configRoutesAgentsTypes.ts @@ -34,6 +34,7 @@ export interface AgentPreparationDeps { export interface AgentConfigStore { loadAgents: typeof configManager.loadAgents; + loadSyntheticAgents?: typeof configManager.loadSyntheticAgents; loadSettings: typeof configManager.loadSettings; loadSettingsRecord?: () => Promise>; loadModelReasoningLevel?: typeof configManager.loadModelReasoningLevel; diff --git a/packages/api/routes/configRoutesSyntheticAgents.ts b/packages/api/routes/configRoutesSyntheticAgents.ts new file mode 100644 index 000000000..816d65dc2 --- /dev/null +++ b/packages/api/routes/configRoutesSyntheticAgents.ts @@ -0,0 +1,153 @@ +import type { Request, Response } from 'express'; +import type { RedisClientType } from 'redis'; +import * as configManager from '@propr/core'; +import { + syntheticAgentConfigsSchema, + validateSyntheticAgentReferences, + validateExecutableSyntheticDefault, + type SyntheticAgentConfig, +} from '@propr/shared'; +import { ConfigRouteError, SETTINGS_CONFIG_LOCK_KEY, withConfigLock } from './configHelpers.js'; +import { saveThenPublishConfigUpdate } from './configRoutesPersistence.js'; + +interface SyntheticAgentConfigRoutesDeps { + redisClient: RedisClientType; + configStore?: Pick< + typeof configManager, + 'loadAgents' | 'loadSettings' | 'loadSyntheticAgents' | 'saveSyntheticAgents' + >; + publishConfigUpdate: (subtype: string) => Promise; + logActivityHelper: ( + description: string, + idSuffix: string, + type: string, + username?: string, + ) => Promise; + refreshAgentRegistry?: () => Promise; +} + +function schemaValidationMessage(issues: Array<{ message: string; path: PropertyKey[] }>): string { + return issues + .map(issue => `synthetic_agents${issue.path.length ? `.${issue.path.join('.')}` : ''}: ${issue.message}`) + .join('; '); +} + +function parseRequestBody(body: unknown): + | { syntheticAgents: SyntheticAgentConfig[] } + | { error: string } { + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return { error: 'Request body must be a JSON object' }; + } + const result = syntheticAgentConfigsSchema.safeParse( + (body as Record).synthetic_agents, + ); + if (!result.success) { + return { error: schemaValidationMessage(result.error.issues) }; + } + return { syntheticAgents: result.data }; +} + +export function createSyntheticAgentConfigRoutes({ + redisClient, + configStore = configManager, + publishConfigUpdate, + logActivityHelper, + refreshAgentRegistry, +}: SyntheticAgentConfigRoutesDeps) { + async function getSyntheticAgents(_req: Request, res: Response): Promise { + try { + res.json({ synthetic_agents: await configStore.loadSyntheticAgents() }); + } catch (error) { + console.error('Error in /api/config/synthetic-agents GET:', error); + res.status(500).json({ error: 'Failed to load synthetic agents configuration' }); + } + } + + async function postSyntheticAgents(req: Request, res: Response): Promise { + const parsed = parseRequestBody(req.body); + if ('error' in parsed) { + res.status(400).json({ error: parsed.error }); + return; + } + + const result = await withConfigLock(redisClient, SETTINGS_CONFIG_LOCK_KEY, async lock => { + const [directAgents, previousSyntheticAgents, settings] = await Promise.all([ + configStore.loadAgents(), + configStore.loadSyntheticAgents(), + configStore.loadSettings(), + ]); + const validation = validateSyntheticAgentReferences(parsed.syntheticAgents, directAgents); + if (validation.errors.length > 0) { + throw new ConfigRouteError(400, { error: validation.errors.join('; ') }); + } + + const configuredDefault = typeof settings.default_agent_alias === 'string' + ? settings.default_agent_alias.trim() + : ''; + const wasSyntheticDefault = previousSyntheticAgents.some(agent => agent.alias === configuredDefault); + const defaultError = validateExecutableSyntheticDefault( + configuredDefault, + parsed.syntheticAgents, + directAgents, + wasSyntheticDefault, + ); + if (defaultError) { + throw new ConfigRouteError(409, { + error: defaultError, + }); + } + + return saveThenPublishConfigUpdate({ + save: () => configStore.saveSyntheticAgents(parsed.syntheticAgents), + publish: () => publishConfigUpdate('synthetic_agents_update'), + lock, + publicationContext: 'synthetic_agents_update', + committedErrorMessage: 'Synthetic agents were saved, but publishing the config update notification failed. Other processes may still be using stale configuration.', + successBody: { + success: true, + synthetic_agents: parsed.syntheticAgents, + warnings: validation.warnings, + }, + }); + }); + + let responseResult = result; + const committed = result.status === 200 || result.body.committed === true; + if (committed && refreshAgentRegistry) { + try { + await refreshAgentRegistry(); + } catch (error) { + console.error('Synthetic agents were saved but the local AgentRegistry refresh failed:', error); + const refreshError = 'The local AgentRegistry refresh failed, so this process may still be using stale synthetic-agent configuration.'; + const existingError = typeof result.body.error === 'string' ? result.body.error : undefined; + responseResult = { + status: 500, + body: { + ...result.body, + success: false, + error: existingError + ? `${existingError} ${refreshError}` + : `Synthetic agents were saved, but the local AgentRegistry refresh failed. This process may still be using stale synthetic-agent configuration.`, + committed: true, + registry_out_of_sync: true, + }, + }; + } + } + if (responseResult.status === 200) { + try { + await logActivityHelper( + `Updated synthetic agents configuration (${parsed.syntheticAgents.length} agents)`, + 'synthetic-agents-update', + 'synthetic_agents_updated', + req.user?.username, + ); + } catch (error) { + console.error('Failed to log synthetic agents configuration activity:', error); + } + } + res.status(responseResult.status).json(responseResult.body); + } + + return { getSyntheticAgents, postSyntheticAgents }; +} diff --git a/packages/api/routes/instanceCatalogRoutes.ts b/packages/api/routes/instanceCatalogRoutes.ts index 220452b45..903ca1445 100644 --- a/packages/api/routes/instanceCatalogRoutes.ts +++ b/packages/api/routes/instanceCatalogRoutes.ts @@ -2,6 +2,7 @@ import type { Request, Response } from 'express'; import { getRepositoriesIndexingStatus, loadAgents, + loadSyntheticAgents, loadMonitoredReposRaw, loadSettings, type AgentConfig, @@ -12,10 +13,12 @@ import type { InstanceCatalogAgent, InstanceCatalogRepository, InstanceCatalogResponse, + SyntheticAgentConfig, } from '@propr/shared'; interface InstanceCatalogServices { loadAgents: () => Promise; + loadSyntheticAgents: () => Promise; loadIndexingStatuses: () => Promise; loadRepositories: () => Promise; loadSettings: () => Promise>; @@ -27,6 +30,8 @@ interface InstanceCatalogRoutesDeps { function catalogAgent(agent: AgentConfig): InstanceCatalogAgent { return { + id: agent.id, + kind: 'direct', alias: agent.alias, enabled: true, supportedModels: [...agent.supportedModels], @@ -34,6 +39,17 @@ function catalogAgent(agent: AgentConfig): InstanceCatalogAgent { }; } +function catalogSyntheticAgent(agent: SyntheticAgentConfig): InstanceCatalogAgent { + return { + id: agent.id, + kind: 'synthetic', + alias: agent.alias, + enabled: true, + supportedModels: agent.models.filter(model => model.enabled).map(model => model.id), + defaultModel: agent.defaultModel, + }; +} + function catalogRepository(repository: RepoToMonitor): InstanceCatalogRepository { return { name: repository.name, @@ -74,20 +90,25 @@ function catalogIndexingStatus(status: RepositoryIndexingStatus): RepositoryInde export function createInstanceCatalogRoutes({ services: overrides }: InstanceCatalogRoutesDeps = {}) { const services: InstanceCatalogServices = { loadAgents, + loadSyntheticAgents, loadIndexingStatuses: getRepositoriesIndexingStatus, loadRepositories: loadMonitoredReposRaw, loadSettings, ...overrides, }; - async function getCatalog(_req: Request, res: Response): Promise { + async function sendCatalog(res: Response, includeSyntheticAgents: boolean): Promise { try { - const [agents, repositories, settings] = await Promise.all([ + const [agents, syntheticAgents, repositories, settings] = await Promise.all([ services.loadAgents(), + includeSyntheticAgents ? services.loadSyntheticAgents() : Promise.resolve([]), services.loadRepositories(), services.loadSettings(), ]); - const catalogAgents = agents.filter(agent => agent.enabled).map(catalogAgent); + const catalogAgents = [ + ...agents.filter(agent => agent.enabled).map(catalogAgent), + ...syntheticAgents.filter(agent => agent.enabled).map(catalogSyntheticAgent), + ]; const defaultAgentAlias = typeof settings.default_agent_alias === 'string' ? settings.default_agent_alias.trim() : ''; @@ -105,6 +126,14 @@ export function createInstanceCatalogRoutes({ services: overrides }: InstanceCat } } + async function getCatalog(_req: Request, res: Response): Promise { + await sendCatalog(res, true); + } + + async function getLegacyCatalog(_req: Request, res: Response): Promise { + await sendCatalog(res, false); + } + async function getRepositoryIndexingStatus(_req: Request, res: Response): Promise { try { const [repositories, statuses] = await Promise.all([ @@ -127,5 +156,5 @@ export function createInstanceCatalogRoutes({ services: overrides }: InstanceCat } } - return { getCatalog, getRepositoryIndexingStatus }; + return { getCatalog, getLegacyCatalog, getRepositoryIndexingStatus }; } diff --git a/packages/api/routes/notificationRoutes.ts b/packages/api/routes/notificationRoutes.ts index e7f96a3fd..df1286d6d 100644 --- a/packages/api/routes/notificationRoutes.ts +++ b/packages/api/routes/notificationRoutes.ts @@ -31,6 +31,7 @@ export type NotificationRouteService = Pick< | 'getUnreadNotificationCount' | 'markNotificationRead' | 'dismissNotification' + | 'dismissAllNotifications' | 'getNotificationPreferences' | 'updateNotificationPreferences' | 'upsertPushSubscription' @@ -239,6 +240,19 @@ export function createNotificationRoutes( } } + async function dismissAll(req: Request, res: Response): Promise { + const userId = authenticatedUserId(req, res); + if (!userId) return; + + try { + res.json(parseNotificationUnreadCountResponse( + await service.dismissAllNotifications(userId) + )); + } catch (error) { + handleRouteError(res, error, 'dismiss all notifications'); + } + } + async function getConfiguration(req: Request, res: Response): Promise { const userId = authenticatedUserId(req, res); if (!userId) return; @@ -351,6 +365,7 @@ export function createNotificationRoutes( getUnreadCount, markRead, dismiss, + dismissAll, getConfiguration, getCapabilities: getConfiguration, getPreferences, diff --git a/packages/api/routes/statusRoutes.ts b/packages/api/routes/statusRoutes.ts index 596c7cb01..2ee6c9820 100644 --- a/packages/api/routes/statusRoutes.ts +++ b/packages/api/routes/statusRoutes.ts @@ -16,9 +16,11 @@ import { AgentRegistry, getIndexingQueue as loadIndexingQueue, loadAgents as loadAgentConfigs, + loadSyntheticAgents as loadSyntheticAgentConfigs, loadSummarizationRuntimeState } from '@propr/core'; import type { Agent, AgentConfig, AgentRegistryOperationalStatus } from '@propr/core'; +import type { SyntheticAgentConfig } from '@propr/shared'; import path from 'node:path'; import os from 'node:os'; import { applyRoutingStatus, parseConnectAccountStatus, type RoutingState } from './connectAccountStatus.js'; @@ -28,6 +30,7 @@ interface StatusRoutesDeps { redisClient: RedisClientType; agentRegistry?: StatusAgentRegistry; loadAgents?: () => Promise; + loadSyntheticAgents?: () => Promise; getIndexingQueue?: () => Promise; agentStatusCacheTtlMs?: number; agentHealthTimeoutMs?: number; @@ -53,9 +56,9 @@ type ServiceStatus = 'connected' | 'disconnected' | 'active' | 'queued' | 'idle' interface AgentStatus { id: string; - type: AgentConfig['type']; + type: AgentConfig['type'] | 'synthetic'; alias: string; - status: 'connected' | 'disconnected'; + status: 'connected' | 'disconnected' | 'degraded'; } export function createStatusRoutes(deps: StatusRoutesDeps) { @@ -63,6 +66,7 @@ export function createStatusRoutes(deps: StatusRoutesDeps) { redisClient, agentRegistry = AgentRegistry.getInstance() as StatusAgentRegistry, loadAgents = loadAgentConfigs, + loadSyntheticAgents: configuredSyntheticLoader, getIndexingQueue = loadIndexingQueue, agentStatusCacheTtlMs = 5000, agentHealthTimeoutMs = 1500, @@ -71,6 +75,11 @@ export function createStatusRoutes(deps: StatusRoutesDeps) { projectSystemSnapshot, getPublicInstanceIdentity: loadPublicInstanceIdentity = getOrCreatePublicInstanceIdentity, } = deps; + // Unit/integration callers that replace the direct config loader predate + // synthetic pools. Treat that fixture as an empty synthetic document unless + // it explicitly supplies one; production still uses persisted configuration. + const loadSyntheticAgents = configuredSyntheticLoader + ?? (deps.loadAgents ? async () => [] : loadSyntheticAgentConfigs); let agentStatusCache: { expiresAt: number; statuses: AgentStatus[] } | undefined; function getCompatibility(_req: Request, res: Response): void { @@ -233,7 +242,7 @@ export function createStatusRoutes(deps: StatusRoutesDeps) { return agentStatusCache.statuses; } - const statuses = await getAgentStatuses(loadAgents, agentRegistry, agentHealthTimeoutMs); + const statuses = await getAgentStatuses(loadAgents, loadSyntheticAgents, agentRegistry, agentHealthTimeoutMs); agentStatusCache = { statuses, expiresAt: currentTime + agentStatusCacheTtlMs @@ -393,16 +402,25 @@ function formatCooldownUntil(until: string): string { async function getAgentStatuses( loadAgents: () => Promise, + loadSyntheticAgents: () => Promise, registry: StatusAgentRegistry, healthTimeoutMs: number ): Promise { let configuredAgents: AgentConfig[]; + let syntheticAgents: SyntheticAgentConfig[] = []; try { configuredAgents = await loadAgents(); } catch (error) { console.error('Error loading agent status configuration:', error); return []; } + try { + syntheticAgents = await loadSyntheticAgents(); + } catch (error) { + // Synthetic configuration availability must not suppress or downgrade + // unrelated direct-agent health. + console.error('Error loading synthetic agent status configuration:', error); + } try { await registry.ensureInitialized(); @@ -410,7 +428,7 @@ async function getAgentStatuses( console.error('Error initializing agent registry for status:', error); } - if (configuredAgents.length === 0) { + if (configuredAgents.length === 0 && syntheticAgents.length === 0) { const defaultAgent = registry.getAgentById('default-claude-agent') ?? registry.getAgentByAlias('default'); if (defaultAgent?.config.type === 'claude') { return [await buildRegisteredAgentStatus(defaultAgent, healthTimeoutMs)]; @@ -421,7 +439,7 @@ async function getAgentStatuses( const registeredById = new Map(registry.getAllAgents().map(agent => [agent.config.id, agent])); const registeredByAlias = new Map(registry.getAllAgents().map(agent => [agent.config.alias, agent])); - return Promise.all(configuredAgents + const directStatuses = await Promise.all(configuredAgents .filter(agent => agent.enabled) .map(async (config) => { const registeredAgent = registeredById.get(config.id) ?? registeredByAlias.get(config.alias); @@ -430,6 +448,27 @@ async function getAgentStatuses( } return buildRegisteredAgentStatus(registeredAgent, healthTimeoutMs); })); + + const syntheticStatuses = await Promise.all(syntheticAgents + .filter(pool => pool.enabled) + .map(async pool => { + const registered = registeredById.get(pool.id) ?? registeredByAlias.get(pool.alias); + if (!registered) return { id: pool.id, type: 'synthetic' as const, alias: pool.alias, status: 'degraded' as const }; + let healthy = false; + try { + healthy = await withTimeout(registered.healthCheck(), healthTimeoutMs, false); + } catch { + healthy = false; + } + return { + id: pool.id, + type: 'synthetic' as const, + alias: pool.alias, + status: healthy ? 'connected' as const : 'degraded' as const, + }; + })); + + return [...directStatuses, ...syntheticStatuses]; } function getDefaultClaudeConfig(): AgentConfig { diff --git a/packages/api/server.ts b/packages/api/server.ts index 2c28f1444..9edffaaa8 100644 --- a/packages/api/server.ts +++ b/packages/api/server.ts @@ -317,7 +317,7 @@ function setupRoutes(): void { ['post', '/api/repos/todos/categories/reorder', repoTodoRoutes.reorderCategories], ['get', '/api/repos/todos', repoTodoRoutes.getTodos], ['get', '/api/repos/todos/:todoId', repoTodoRoutes.getTodo], ['post', '/api/repos/todos', repoTodoRoutes.createTodo], ['put', '/api/repos/todos/:todoId', repoTodoRoutes.updateTodo], ['delete', '/api/repos/todos/:todoId', repoTodoRoutes.deleteTodo], ['post', '/api/repos/todos/reorder', repoTodoRoutes.reorderTodos], ['get', '/api/user/repo-preferences', userRepoPreferencesRoutes.getRepoPreferences], ['post', '/api/user/repo-preferences', userRepoPreferencesRoutes.updateRepoPreferences], ['get', '/api/notifications', notificationRoutes.getNotifications], ['get', '/api/notifications/unread-count', notificationRoutes.getUnreadCount], ['get', '/api/notifications/config', notificationRoutes.getConfiguration], ['get', '/api/notifications/capabilities', notificationRoutes.getCapabilities], - ['get', '/api/notifications/preferences', notificationRoutes.getPreferences], ['patch', '/api/notifications/preferences', notificationRoutes.updatePreferences], ['get', '/api/notifications/push-subscriptions', notificationRoutes.listPushSubscriptions], ['post', '/api/notifications/push-subscriptions', notificationRoutes.createPushSubscription], ['delete', '/api/notifications/push-subscriptions', notificationRoutes.revokePushSubscription], ['delete', '/api/notifications/push-subscriptions/:subscriptionId', notificationRoutes.revokePushSubscriptionById], ['post', '/api/notifications/:id/read', notificationRoutes.markRead], ['post', '/api/notifications/:id/dismiss', notificationRoutes.dismiss], + ['get', '/api/notifications/preferences', notificationRoutes.getPreferences], ['patch', '/api/notifications/preferences', notificationRoutes.updatePreferences], ['get', '/api/notifications/push-subscriptions', notificationRoutes.listPushSubscriptions], ['post', '/api/notifications/push-subscriptions', notificationRoutes.createPushSubscription], ['delete', '/api/notifications/push-subscriptions', notificationRoutes.revokePushSubscription], ['delete', '/api/notifications/push-subscriptions/:subscriptionId', notificationRoutes.revokePushSubscriptionById], ['post', '/api/notifications/dismiss-all', notificationRoutes.dismissAll], ['post', '/api/notifications/:id/read', notificationRoutes.markRead], ['post', '/api/notifications/:id/dismiss', notificationRoutes.dismiss], ]; const routes = [ ...operationalRoutes, diff --git a/packages/api/services/notificationProjectionService.ts b/packages/api/services/notificationProjectionService.ts index e4e80cf2a..2e5dbb20f 100644 --- a/packages/api/services/notificationProjectionService.ts +++ b/packages/api/services/notificationProjectionService.ts @@ -12,7 +12,6 @@ import { type IndexingUpdatePayload, type JsonObject, type NotificationEventAction, - type NotificationKind, type TaskUpdatePayload, } from '@propr/shared'; @@ -27,12 +26,10 @@ interface ProjectionLogger { warn(message: string, error?: unknown): void; } -interface NotificationEventWriter { - createNotificationEvent( - input: CreateNotificationEventInput, - recipients?: readonly NotificationRecipient[], - ): Promise; -} +type NotificationEventWriter = Pick; export interface NotificationProjectionOptions { database: Knex; @@ -52,11 +49,24 @@ interface TaskContext { repository: string; issueNumber?: number; prNumber?: number; + description?: string; isReview: boolean; followupEligible: boolean; reviewFollowupEligible: boolean; } +interface TaskEventProjection { + payload: TaskUpdatePayload; + context: TaskContext; + occurredAt: string; + recipients: readonly NotificationRecipient[]; + pullRequestUrl?: string; +} + +interface PullRequestTaskEventProjection extends TaskEventProjection { + prNumber: number; +} + interface SourceActivityRow { activity_type: 'task' | 'indexing'; activity_key: string; @@ -67,11 +77,6 @@ interface SourceActivityRow { metadata_json: string | null; } -interface SystemFailureTransition { - status: string; - occurredAt: string; -} - const SYSTEM_HEALTH_RULES: Readonly>> = { api: new Set(['healthy']), redis: new Set(['connected']), @@ -101,6 +106,27 @@ function parseJsonObject(value: unknown): Record { } } +function compactDisplayText(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const normalized = value.replace(/\s+/g, ' ').trim(); + if (!normalized) return undefined; + const characters = Array.from(normalized); + return characters.length <= 320 + ? normalized + : `${characters.slice(0, 319).join('')}…`; +} + +function taskDescription(initial: Record): string | undefined { + const issueRef = typeof initial.issueRef === 'object' + && initial.issueRef !== null + && !Array.isArray(initial.issueRef) + ? initial.issueRef as Record + : {}; + return compactDisplayText(initial.subtitle) + ?? compactDisplayText(initial.title) + ?? compactDisplayText(issueRef.title); +} + function stableKey(scope: string, ...parts: unknown[]): string { const digest = createHash('sha256').update(JSON.stringify(parts)).digest('hex'); return `projection:v1:${scope}:${digest}`; @@ -202,8 +228,6 @@ export class NotificationProjectionService { private readonly stalledAfterMs: number; private readonly stalledCheckIntervalMs: number; private readonly logger: ProjectionLogger; - private readonly systemFailures = new Map(); - private readonly latestSystemSnapshotAt = new Map(); private stalledTimer: NodeJS.Timeout | undefined; constructor(options: NotificationProjectionOptions) { @@ -289,84 +313,27 @@ export class NotificationProjectionService { ? undefined : safeGithubPullRequestUrl(context.repository, context.prNumber); if (payload.state === 'failed') { - await this.notifications.createNotificationEvent({ - deduplicationKey: stableKey('task-failed', payload.taskId, payload.state, occurredAt), - kind: 'task', - severity: 'error', - target: { - type: 'task', repository: context.repository, taskId: payload.taskId, - ...(context.issueNumber === undefined ? {} : { issueNumber: context.issueNumber }), - ...(context.prNumber === undefined ? {} : { prNumber: context.prNumber }), - }, - title: 'Task failed', - body: `Work for ${context.repository} did not complete.`, - actions: taskActions({ - followup: context.followupEligible, - hasPullRequest: pullRequestUrl !== undefined, - }), - ...pullRequestAction(pullRequestUrl), - occurredAt, - }, recipients); + await this.projectFailedTask({ + payload, context, occurredAt, recipients, pullRequestUrl, + }); return; } if (payload.state !== 'completed') return; if (context.isReview && context.prNumber !== undefined) { - await this.notifications.createNotificationEvent({ - deduplicationKey: stableKey('review-completed', payload.taskId, payload.state, occurredAt), - kind: 'review', - severity: 'success', - target: { - type: 'review', repository: context.repository, - prNumber: context.prNumber, taskId: payload.taskId, - }, - title: 'Review completed', - body: `Review of PR #${context.prNumber} is complete.`, - actions: taskActions({ - followup: context.reviewFollowupEligible, - hasPullRequest: pullRequestUrl !== undefined, - }), - ...pullRequestAction(pullRequestUrl), - occurredAt, - }, recipients); - } else { - await this.notifications.createNotificationEvent({ - deduplicationKey: stableKey('implementation-completed', payload.taskId, payload.state, occurredAt), - kind: 'task', - severity: 'success', - target: { - type: 'task', repository: context.repository, taskId: payload.taskId, - ...(context.issueNumber === undefined ? {} : { issueNumber: context.issueNumber }), - ...(context.prNumber === undefined ? {} : { prNumber: context.prNumber }), - }, - title: 'Implementation completed', - body: `Implementation work for ${context.repository} is complete.`, - actions: taskActions({ - followup: context.followupEligible, - hasPullRequest: pullRequestUrl !== undefined, - }), - ...pullRequestAction(pullRequestUrl), - occurredAt, - }, recipients); + await this.projectCompletedReview( + { payload, context, occurredAt, recipients, pullRequestUrl, prNumber: context.prNumber }, + ); + } else if (context.prNumber === undefined) { + await this.projectCompletedImplementation( + { payload, context, occurredAt, recipients, pullRequestUrl }, + ); } - if (context.prNumber !== undefined) { - await this.notifications.createNotificationEvent({ - deduplicationKey: stableKey('pr-attention', payload.taskId, context.prNumber, occurredAt), - kind: 'pull_request', - severity: 'info', - target: { - type: 'pull_request', repository: context.repository, prNumber: context.prNumber, - }, - title: 'Pull request needs attention', - body: `PR #${context.prNumber} is ready for attention.`, - actions: [ - ...(pullRequestUrl === undefined ? [] : ['open_pr' as const]), - 'dismiss', - ], - ...pullRequestAction(pullRequestUrl), - occurredAt, - }, recipients); + if (!context.isReview && context.prNumber !== undefined) { + await this.projectPullRequestAttention( + { payload, context, occurredAt, recipients, pullRequestUrl, prNumber: context.prNumber }, + ); } } @@ -415,7 +382,7 @@ export class NotificationProjectionService { if (row.activity_type === 'task') { const issueNumber = positiveInteger(metadata.issueNumber); const prNumber = positiveInteger(metadata.prNumber); - await this.notifications.createNotificationEvent({ + await this.createPullRequestAwareEvent({ deduplicationKey: stableKey( 'task-stalled', row.activity_key, row.status, row.last_activity_at, ), @@ -430,7 +397,7 @@ export class NotificationProjectionService { body: `Active work for ${row.repository} has not reported progress.`, actions: taskActions({ active: true }), occurredAt: row.last_activity_at, - }, await this.loadInstanceMemberRecipients()); + }, await this.loadInstanceMemberRecipients(), row.repository, prNumber); } else { await this.notifications.createNotificationEvent({ deduplicationKey: stableKey( @@ -461,32 +428,149 @@ export class NotificationProjectionService { for (const [component, healthyValues] of Object.entries(SYSTEM_HEALTH_RULES)) { const rawStatus = snapshot[component]; if (typeof rawStatus !== 'string') continue; - const latestSnapshotAt = this.latestSystemSnapshotAt.get(component); - if (latestSnapshotAt !== undefined && snapshotAt < latestSnapshotAt) continue; - this.latestSystemSnapshotAt.set(component, snapshotAt); - if (healthyValues.has(rawStatus)) { - this.systemFailures.delete(component); - continue; - } + const healthy = healthyValues.has(rawStatus); + await this.notifications.reconcileSystemFailureTransition({ + component, + status: rawStatus, + healthy, + snapshotAt, + eventFor: (status, failureStartedAt) => ({ + deduplicationKey: stableKey( + 'system-failure', component, status, failureStartedAt, + ), + kind: 'system_failure', + severity: 'error', + target: { type: 'system_failure', component }, + title: 'System component unhealthy', + body: `${component} is not reporting a healthy status.`, + actions: ['dismiss'], + occurredAt: failureStartedAt, + }), + }, recipients); + } + } - let transition = this.systemFailures.get(component); - if (!transition || transition.status !== rawStatus) { - transition = { status: rawStatus, occurredAt: snapshotAt }; - this.systemFailures.set(component, transition); - } - await this.notifications.createNotificationEvent({ + private createPullRequestAwareEvent( + input: CreateNotificationEventInput, + recipients: readonly NotificationRecipient[], + repository: string, + prNumber: number | undefined, + ): Promise<{ id: string } | null> { + if (prNumber === undefined) { + return this.notifications.createNotificationEvent(input, recipients); + } + return this.notifications.createPullRequestNotificationEvent( + repository, + prNumber, + input, + recipients, + ); + } + + private projectFailedTask(input: TaskEventProjection): Promise<{ id: string } | null> { + const { payload, context, occurredAt, recipients, pullRequestUrl } = input; + return this.createPullRequestAwareEvent({ + deduplicationKey: stableKey('task-failed', payload.taskId, payload.state, occurredAt), + kind: 'task', + severity: 'error', + target: { + type: 'task', repository: context.repository, taskId: payload.taskId, + ...(context.issueNumber === undefined ? {} : { issueNumber: context.issueNumber }), + ...(context.prNumber === undefined ? {} : { prNumber: context.prNumber }), + }, + title: context.prNumber !== undefined + ? `Task failed for PR #${context.prNumber}` + : context.issueNumber !== undefined + ? `Task failed for issue #${context.issueNumber}` + : 'Task failed', + body: context.description ?? `Work for ${context.repository} did not complete.`, + actions: taskActions({ + followup: context.followupEligible, + hasPullRequest: pullRequestUrl !== undefined, + }), + ...pullRequestAction(pullRequestUrl), + occurredAt, + }, recipients, context.repository, context.prNumber); + } + + private projectCompletedReview( + input: PullRequestTaskEventProjection, + ): Promise<{ id: string } | null> { + const { payload, context, occurredAt, recipients, pullRequestUrl, prNumber } = input; + return this.createPullRequestAwareEvent({ + deduplicationKey: stableKey('review-completed', payload.taskId, payload.state, occurredAt), + kind: 'review', + severity: 'success', + target: { + type: 'review', repository: context.repository, + prNumber, taskId: payload.taskId, + }, + title: `Review completed for PR #${prNumber}`, + body: context.description ?? `Review of PR #${prNumber} is complete.`, + actions: taskActions({ + followup: context.reviewFollowupEligible, + hasPullRequest: pullRequestUrl !== undefined, + }), + ...pullRequestAction(pullRequestUrl), + occurredAt, + }, recipients, context.repository, prNumber); + } + + private projectCompletedImplementation( + input: TaskEventProjection, + ): Promise<{ id: string } | null> { + const { payload, context, occurredAt, recipients, pullRequestUrl } = input; + return this.createPullRequestAwareEvent({ + deduplicationKey: stableKey('implementation-completed', payload.taskId, payload.state, occurredAt), + kind: 'task', + severity: 'success', + target: { + type: 'task', repository: context.repository, taskId: payload.taskId, + ...(context.issueNumber === undefined ? {} : { issueNumber: context.issueNumber }), + ...(context.prNumber === undefined ? {} : { prNumber: context.prNumber }), + }, + title: context.issueNumber === undefined + ? 'Implementation completed' + : `Implementation completed for issue #${context.issueNumber}`, + body: context.description + ?? `Implementation work for ${context.repository} is complete.`, + actions: taskActions({ + followup: context.followupEligible, + hasPullRequest: pullRequestUrl !== undefined, + }), + ...pullRequestAction(pullRequestUrl), + occurredAt, + }, recipients, context.repository, context.prNumber); + } + + private projectPullRequestAttention( + input: PullRequestTaskEventProjection, + ): Promise<{ id: string } | null> { + const { payload, context, occurredAt, recipients, pullRequestUrl, prNumber } = input; + return this.notifications.createPullRequestAttentionNotificationEvent( + context.repository, + prNumber, + { deduplicationKey: stableKey( - 'system-failure', component, transition.status, transition.occurredAt, + 'pr-attention', payload.taskId, prNumber, occurredAt, ), - kind: 'system_failure', - severity: 'error', - target: { type: 'system_failure', component }, - title: 'System component unhealthy', - body: `${component} is not reporting a healthy status.`, - actions: ['dismiss'], - occurredAt: transition.occurredAt, - }, recipients); - } + kind: 'pull_request', + severity: 'info', + target: { + type: 'pull_request', repository: context.repository, prNumber, + }, + title: `PR #${prNumber} ready for review`, + body: context.description + ?? `Implementation is complete; review the changes in ${context.repository}.`, + actions: [ + ...(pullRequestUrl === undefined ? [] : ['open_pr' as const]), + 'dismiss', + ], + ...pullRequestAction(pullRequestUrl), + occurredAt, + }, + recipients, + ); } private async loadTaskContext(payload: TaskUpdatePayload): Promise { @@ -527,6 +611,7 @@ export class NotificationProjectionService { repository, issueNumber, prNumber, + description: taskDescription(initial), isReview, followupEligible: supportsTaskFollowup(task, issueNumber), reviewFollowupEligible: supportsTaskFollowup(task, prNumber), diff --git a/packages/api/test/configRepoRoutes.test.ts b/packages/api/test/configRepoRoutes.test.ts new file mode 100644 index 000000000..96ca416e9 --- /dev/null +++ b/packages/api/test/configRepoRoutes.test.ts @@ -0,0 +1,152 @@ +import assert from 'node:assert/strict'; +import { after, mock, test } from 'node:test'; + +process.env.PROPR_DEMO_MODE = 'true'; +const [{ createConfigRoutes }, { db }] = await Promise.all([ + import('../routes/configRoutes.js'), + import('@propr/core') +]); + +after(async () => { + await db.destroy(); +}); + +function createResponse() { + return { + statusCode: 200, + body: undefined as Record | undefined, + status(code: number) { + this.statusCode = code; + return this; + }, + json(payload: Record) { + this.body = payload; + return this; + } + }; +} + +test('GET repository config returns false for legacy entries with a missing option', async () => { + const routes = createConfigRoutes({ + redisClient: {} as never, + configStore: { + loadMonitoredReposRaw: async () => [{ id: 'repo-1', name: 'integry/propr', enabled: true }] + } + }); + const response = createResponse(); + + await routes.getRepos({} as never, response as never); + + assert.equal(response.statusCode, 200); + assert.deepEqual(response.body, { + repos_to_monitor: [{ + id: 'repo-1', + name: 'integry/propr', + enabled: true, + autoFollowupOnFailedCi: false + }] + }); +}); + +test('POST repository config persists an enabled option without enabling other repositories', async () => { + const saveMonitoredRepos = mock.fn(async () => true); + const routes = createConfigRoutes({ + redisClient: { + set: mock.fn(async () => 'OK'), + eval: mock.fn(async () => 1), + publish: mock.fn(async () => 1), + lPush: mock.fn(async () => 1), + lTrim: mock.fn(async () => 'OK') + } as never, + configStore: { + loadMonitoredReposRaw: async () => [], + saveMonitoredRepos, + clearRemovedRepositoryIndexData: async () => {} + }, + database: { + transaction: async (callback: (transaction: never) => Promise) => callback({} as never) + } as never + }); + const response = createResponse(); + + await routes.postRepos({ + body: { + repos_to_monitor: [ + { id: 'repo-1', name: 'integry/propr', enabled: true, autoFollowupOnFailedCi: true }, + { id: 'repo-2', name: 'integry/other', enabled: true } + ] + } + } as never, response as never); + + assert.equal(response.statusCode, 200); + assert.equal(saveMonitoredRepos.mock.calls.length, 1); + assert.deepEqual(saveMonitoredRepos.mock.calls[0]?.arguments[0], [ + { + id: 'repo-1', + name: 'integry/propr', + enabled: true, + autoFollowupOnFailedCi: true, + alias: undefined, + baseBranch: undefined, + defaultBranch: undefined + }, + { + id: 'repo-2', + name: 'integry/other', + enabled: true, + autoFollowupOnFailedCi: false, + alias: undefined, + baseBranch: undefined, + defaultBranch: undefined + } + ]); +}); + +test('POST repository config preserves an omitted option for existing repositories', async () => { + const saveMonitoredRepos = mock.fn(async () => true); + const routes = createConfigRoutes({ + redisClient: { + set: mock.fn(async () => 'OK'), + eval: mock.fn(async () => 1), + publish: mock.fn(async () => 1), + lPush: mock.fn(async () => 1), + lTrim: mock.fn(async () => 'OK') + } as never, + configStore: { + loadMonitoredReposRaw: async () => [ + { id: 'repo-1', name: 'integry/propr', enabled: false, autoFollowupOnFailedCi: true }, + { id: 'repo-2', name: 'integry/other', enabled: true, autoFollowupOnFailedCi: true } + ], + saveMonitoredRepos, + clearRemovedRepositoryIndexData: async () => {} + }, + database: { + transaction: async (callback: (transaction: never) => Promise) => callback({} as never) + } as never + }); + const response = createResponse(); + + await routes.postRepos({ + body: { + repos_to_monitor: [ + { id: 'repo-1', name: 'integry/propr', enabled: true }, + { id: 'repo-2', name: 'integry/other', enabled: true, autoFollowupOnFailedCi: false }, + { id: 'repo-3', name: 'integry/new', enabled: true } + ] + } + } as never, response as never); + + assert.equal(response.statusCode, 200); + assert.equal(saveMonitoredRepos.mock.calls.length, 1); + assert.deepEqual( + saveMonitoredRepos.mock.calls[0]?.arguments[0].map(repo => ({ + id: repo.id, + autoFollowupOnFailedCi: repo.autoFollowupOnFailedCi + })), + [ + { id: 'repo-1', autoFollowupOnFailedCi: true }, + { id: 'repo-2', autoFollowupOnFailedCi: false }, + { id: 'repo-3', autoFollowupOnFailedCi: false } + ] + ); +}); diff --git a/packages/api/test/configRepoValidation.test.ts b/packages/api/test/configRepoValidation.test.ts new file mode 100644 index 000000000..19dc67e89 --- /dev/null +++ b/packages/api/test/configRepoValidation.test.ts @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { normalizeRepoConfig } from '../routes/configRepoValidation.js'; + +test('repository config defaults missing automatic failed-CI follow-up to false', () => { + const normalized = normalizeRepoConfig({ + id: 'repo-1', + name: 'integry/propr', + enabled: true + }); + + assert.equal(normalized.ok, true); + if (normalized.ok) { + assert.equal(normalized.value.autoFollowupOnFailedCi, false); + } +}); + +test('repository config accepts explicit automatic failed-CI follow-up booleans', () => { + for (const autoFollowupOnFailedCi of [true, false]) { + const normalized = normalizeRepoConfig({ + id: `repo-${autoFollowupOnFailedCi}`, + name: 'integry/propr', + enabled: true, + autoFollowupOnFailedCi + }); + + assert.equal(normalized.ok, true); + if (normalized.ok) { + assert.equal(normalized.value.autoFollowupOnFailedCi, autoFollowupOnFailedCi); + } + } +}); + +test('repository config rejects non-boolean automatic failed-CI follow-up values', () => { + for (const autoFollowupOnFailedCi of ['true', 1, null, {}]) { + const normalized = normalizeRepoConfig({ + id: 'repo-1', + name: 'integry/propr', + enabled: true, + autoFollowupOnFailedCi + }); + + assert.equal(normalized.ok, false); + if (!normalized.ok) { + assert.match(normalized.error, /autoFollowupOnFailedCi.*must be a boolean/); + } + } +}); diff --git a/packages/api/test/instanceAuthorization.test.ts b/packages/api/test/instanceAuthorization.test.ts index d04717b20..623db0178 100644 --- a/packages/api/test/instanceAuthorization.test.ts +++ b/packages/api/test/instanceAuthorization.test.ts @@ -258,6 +258,7 @@ describe('instance catalog', () => { defaultModel: 'gpt-5.4', envVars: { SECRET_TOKEN: 'secret' } }], + loadSyntheticAgents: async () => [], loadRepositories: async () => [ { id: 'repo-1', name: 'integry/propr', enabled: true, baseBranch: 'main' }, { id: 'repo-2', name: 'integry/private-disabled', enabled: false } @@ -275,6 +276,8 @@ describe('instance catalog', () => { assert.equal(record.status, 200); assert.deepEqual(record.body, { agents: [{ + id: 'agent-1', + kind: 'direct', alias: 'default', enabled: true, supportedModels: ['gpt-5.4'], diff --git a/packages/api/test/notificationManagementRoutes.test.ts b/packages/api/test/notificationManagementRoutes.test.ts index fc5370563..06fbb0e21 100644 --- a/packages/api/test/notificationManagementRoutes.test.ts +++ b/packages/api/test/notificationManagementRoutes.test.ts @@ -36,6 +36,7 @@ function routeService( getUnreadNotificationCount: async () => 0, markNotificationRead: async () => null, dismissNotification: async () => null, + dismissAllNotifications: async () => ({ unreadCount: 0 }), getNotificationPreferences: async () => preferences, updateNotificationPreferences: async () => preferences, upsertPushSubscription: async () => subscription, diff --git a/packages/api/test/notificationProjectionRace.test.ts b/packages/api/test/notificationProjectionRace.test.ts new file mode 100644 index 000000000..ad6541101 --- /dev/null +++ b/packages/api/test/notificationProjectionRace.test.ts @@ -0,0 +1,112 @@ +import assert from 'node:assert/strict'; +import { after, afterEach, beforeEach, describe, test } from 'node:test'; +import type { Knex } from 'knex'; +import { closeConnection, NotificationService } from '@propr/core'; +import { TASK_UPDATE } from '@propr/shared'; +import type { NotificationProjectionService } from '../services/notificationProjectionService.js'; +import { + countNotificationEvents, + countUndismissedNotificationReceipts, + createNotificationProjectionTestHarness, + listActiveNotificationReceipts, +} from './notificationProjectionTestHarness.js'; + +let database: Knex; +let projection: NotificationProjectionService; +let clock: number; +const iso = (offsetMs = 0): string => new Date(clock + offsetMs).toISOString(); + +beforeEach(async () => { + clock = Date.now() - 60_000; + ({ database, projection } = await createNotificationProjectionTestHarness( + () => new Date(clock), + )); +}); + +afterEach(async () => { + projection.close(); + await database.destroy(); +}); + +after(async () => closeConnection()); + +describe('notification projection lifecycle races', { concurrency: false }, () => { + test('replaces an older PR-attention card while preserving both audit events', async () => { + const firstAt = iso(); + const secondAt = iso(1_000); + await database('tasks').insert([ + { + task_id: 'pr-work-first', repository: 'integry/propr', issue_number: 42, + pr_number: 42, task_type: 'pr-comment', initial_job_data: '{}', + }, + { + task_id: 'pr-work-second', repository: 'integry/propr', issue_number: 42, + pr_number: 42, task_type: 'pr-comment', initial_job_data: '{}', + }, + ]); + await database('task_history').insert([ + { task_id: 'pr-work-first', state: 'completed', timestamp: firstAt, metadata: '{}' }, + { task_id: 'pr-work-second', state: 'completed', timestamp: secondAt, metadata: '{}' }, + ]); + + await projection.projectTaskUpdate({ + eventType: TASK_UPDATE, taskId: 'pr-work-first', state: 'completed', + repository: 'integry/propr', timestamp: firstAt, + }); + clock += 1_000; + await projection.projectTaskUpdate({ + eventType: TASK_UPDATE, taskId: 'pr-work-second', state: 'completed', + repository: 'integry/propr', timestamp: secondAt, + }); + + const attentionEvents = await database('notification_events') + .where({ kind: 'pull_request' }); + assert.equal(attentionEvents.length, 2, 'immutable audit events are retained'); + const visibleReceipts = await listActiveNotificationReceipts(database, 'pull_request'); + assert.deepEqual(visibleReceipts.map(row => row.user_id).sort(), [ + 'admin-user', 'member-user', + ]); + assert.ok(visibleReceipts.every(row => row.occurred_at === secondAt)); + }); + + test('does not recreate PR notifications after the durable merge transition', async () => { + const beforeMergeAt = iso(); + const delayedAt = iso(1_000); + await database('tasks').insert([ + { + task_id: 'pr-before-merge', repository: 'integry/propr', issue_number: 42, + pr_number: 42, task_type: 'pr-comment', initial_job_data: '{}', + }, + { + task_id: 'pr-delayed-after-merge', repository: 'integry/propr', issue_number: 42, + pr_number: 42, task_type: 'pr-comment', initial_job_data: '{}', + }, + ]); + await database('task_history').insert([ + { task_id: 'pr-before-merge', state: 'completed', timestamp: beforeMergeAt, metadata: '{}' }, + { task_id: 'pr-delayed-after-merge', state: 'completed', timestamp: delayedAt, metadata: '{}' }, + ]); + + await projection.projectTaskUpdate({ + eventType: TASK_UPDATE, taskId: 'pr-before-merge', state: 'completed', + repository: 'integry/propr', timestamp: beforeMergeAt, + }); + const notifications = new NotificationService({ database, now: () => new Date(clock) }); + await notifications.markPullRequestMergedAndDismissNotifications( + 'integry/propr', 42, iso(500), + ); + clock += 1_000; + await projection.projectTaskUpdate({ + eventType: TASK_UPDATE, taskId: 'pr-delayed-after-merge', state: 'completed', + repository: 'integry/propr', timestamp: delayedAt, + }); + + assert.equal(await countNotificationEvents(database), 1); + assert.equal(await countUndismissedNotificationReceipts(database, 'task'), 0); + assert.equal(await countUndismissedNotificationReceipts(database, 'pull_request'), 0); + assert.deepEqual( + await database('notification_pull_request_state').select('repository', 'pr_number'), + [{ repository: 'integry/propr', pr_number: 42 }], + ); + }); +}); diff --git a/packages/api/test/notificationProjectionService.test.ts b/packages/api/test/notificationProjectionService.test.ts index c3cb8b3af..2bef7d8f8 100644 --- a/packages/api/test/notificationProjectionService.test.ts +++ b/packages/api/test/notificationProjectionService.test.ts @@ -1,87 +1,25 @@ import assert from 'node:assert/strict'; import { after, afterEach, beforeEach, describe, test } from 'node:test'; -import knex, { type Knex } from 'knex'; +import type { Knex } from 'knex'; import { closeConnection, NotificationService } from '@propr/core'; import { DRAFT_UPDATE, INDEXING_UPDATE, TASK_UPDATE } from '@propr/shared'; -import { up as createNotificationSchema } from '../../core/src/db/migrations/20260802000000_create_notification_schema.js'; -import { up as addNotificationPreferenceApis } from '../../core/src/db/migrations/20260802010000_add_notification_preference_apis.js'; -import { up as addAdvertisedActions } from '../../core/src/db/migrations/20260824020000_add_notification_advertised_actions.js'; import { NotificationProjectionService } from '../services/notificationProjectionService.js'; +import { + countNotificationEvents, countUndismissedNotificationReceipts, + createNotificationProjectionTestHarness, +} from './notificationProjectionTestHarness.js'; let database: Knex; let clock: number; let projection: NotificationProjectionService; -function iso(offsetMs = 0): string { - return new Date(clock + offsetMs).toISOString(); -} - -async function eventCount(): Promise { - return database('notification_events') - .count('* as count') - .first() - .then(row => Number(row?.count ?? 0)); -} - -async function createProjectionTables(db: Knex): Promise { - await db.schema.createTable('tasks', table => { - table.text('task_id').primary(); - table.text('repository').notNullable(); - table.integer('issue_number').nullable(); - table.integer('pr_number').nullable(); - table.text('task_type').notNullable(); - table.text('initial_job_data').nullable(); - }); - await db.schema.createTable('task_history', table => { - table.increments('history_id').primary(); - table.text('task_id').notNullable(); - table.text('state').notNullable(); - table.text('timestamp').notNullable(); - table.text('metadata').nullable(); - }); - await db.schema.createTable('task_drafts', table => { - table.text('draft_id').primary(); - table.text('user_id').notNullable(); - table.text('repository').notNullable(); - }); - await db.schema.createTable('instance_members', table => { - table.text('github_user_id').primary(); - table.text('role').notNullable(); - }); -} +const iso = (offsetMs = 0): string => new Date(clock + offsetMs).toISOString(); beforeEach(async () => { clock = Date.now() - 60_000; - database = knex({ - client: 'better-sqlite3', - connection: { filename: ':memory:' }, - useNullAsDefault: true, - pool: { - afterCreate(connection: { pragma(statement: string): void }, done: (error: Error | null, connection: unknown) => void) { - connection.pragma('foreign_keys = ON'); - connection.pragma('recursive_triggers = ON'); - done(null, connection); - }, - }, - }); - await createProjectionTables(database); - await createNotificationSchema(database); - await addNotificationPreferenceApis(database); - await addAdvertisedActions(database); - const notificationService = new NotificationService({ - database, - now: () => new Date(clock), - }); - projection = new NotificationProjectionService({ - database, - notificationService, - now: () => new Date(clock), - stalledAfterMs: 10_000, - }); - await database('instance_members').insert([ - { github_user_id: 'admin-user', role: 'admin' }, - { github_user_id: 'member-user', role: 'member' }, - ]); + ({ database, projection } = await createNotificationProjectionTestHarness( + () => new Date(clock), + )); }); afterEach(async () => { @@ -120,11 +58,15 @@ describe('notification lifecycle projection', { concurrency: false }, () => { ); }); - test('separates implementation, review, and sanitized PR-attention events', async () => { + test('emits one descriptive notification for each completed task', async () => { const implementationAt = iso(); await database('tasks').insert({ task_id: 'implementation-1', repository: 'integry/propr', issue_number: 1719, - pr_number: null, task_type: 'issue', initial_job_data: '{}', + pr_number: null, task_type: 'issue', + initial_job_data: JSON.stringify({ + title: 'Follow-up PR #42: Deduplicate Inbox notifications', + subtitle: 'Keep only the newest actionable Inbox update.', + }), }); await database('task_history').insert({ task_id: 'implementation-1', state: 'completed', timestamp: implementationAt, @@ -151,7 +93,11 @@ describe('notification lifecycle projection', { concurrency: false }, () => { await database('tasks').insert({ task_id: 'pr-comments-batch-integry-propr-7', repository: 'integry/propr', issue_number: 1719, pr_number: null, task_type: 'issue', - initial_job_data: JSON.stringify({ number: 7, commentBody: 'SECRET COMMENT' }), + initial_job_data: JSON.stringify({ + number: 7, + title: 'Review PR #7 notification behavior', + commentBody: 'SECRET COMMENT', + }), }); await database('task_history').insert({ task_id: 'pr-comments-batch-integry-propr-7', state: 'completed', @@ -166,14 +112,24 @@ describe('notification lifecycle projection', { concurrency: false }, () => { }); const events = await database('notification_events') - .select('kind', 'title', 'action_json') - .orderBy('occurred_at') as Array<{ kind: string; title: string; action_json: string | null }>; + .select('kind', 'title', 'body', 'action_json') + .orderBy('occurred_at') as Array<{ + kind: string; title: string; body: string; action_json: string | null; + }>; assert.deepEqual( events.map(event => event.kind).sort(), - ['pull_request', 'pull_request', 'review', 'task'], + ['pull_request', 'review'], ); - assert.ok(events.some(event => event.title === 'Implementation completed')); - assert.ok(events.some(event => event.title === 'Review completed')); + assert.deepEqual(events.map(event => ({ title: event.title, body: event.body })), [ + { + title: 'PR #42 ready for review', + body: 'Keep only the newest actionable Inbox update.', + }, + { + title: 'Review completed for PR #7', + body: 'Review PR #7 notification behavior', + }, + ]); const implementationPrEvent = events.find(event => event.action_json?.includes('/pull/42')); assert.equal( @@ -181,7 +137,7 @@ describe('notification lifecycle projection', { concurrency: false }, () => { 'https://github.com/integry/propr/pull/42', ); assert.doesNotMatch(JSON.stringify(events), /evil\.example|SECRET/); - assert.equal(await eventCount(), 4); + assert.equal(await countNotificationEvents(database), 2); }); test('ignores stale task transitions and emits one stalled event per unchanged activity', async () => { @@ -236,7 +192,7 @@ describe('notification lifecycle projection', { concurrency: false }, () => { const events = await database('notification_events').select('*'); assert.equal(events.length, 1); - assert.equal(events[0].title, 'Task failed'); + assert.equal(events[0].title, 'Task failed for issue #99'); assert.doesNotMatch(JSON.stringify(events[0]), /SECRET/); assert.deepEqual( (await database('notification_user_states').pluck('user_id')).sort(), @@ -269,13 +225,11 @@ describe('notification lifecycle projection', { concurrency: false }, () => { advertised_actions_json: string; }>; assert.deepEqual(events.map(event => event.title), [ - 'Implementation completed', - 'Pull request needs attention', + 'PR #42 ready for review', ]); assert.ok(events.every(event => event.action_json === null)); assert.deepEqual(events.map(event => JSON.parse(event.advertised_actions_json)), [ ['dismiss'], - ['dismiss'], ]); }); @@ -325,10 +279,14 @@ describe('notification lifecycle projection', { concurrency: false }, () => { const listed = await new NotificationService({ database }).listNotifications('admin-user'); const lifecycleEvents = listed.notifications.filter(notification => [ - 'Task failed', 'Implementation completed', 'Review completed', + 'Task failed for issue #101', + 'Implementation completed for issue #102', + 'Review completed for PR #7', ].includes(notification.title)); assert.deepEqual(lifecycleEvents.map(notification => notification.title).sort(), [ - 'Implementation completed', 'Review completed', 'Task failed', + 'Implementation completed for issue #102', + 'Review completed for PR #7', + 'Task failed for issue #101', ]); assert.ok(lifecycleEvents.every(notification => !notification.actions.includes('follow_up'))); }); @@ -373,14 +331,14 @@ describe('notification lifecycle projection', { concurrency: false }, () => { await projection.projectIndexingUpdate(payload); await projection.projectIndexingUpdate(payload); - assert.equal(await eventCount(), 1); + assert.equal(await countNotificationEvents(database), 1); assert.deepEqual( await database('notification_user_states').pluck('user_id'), ['admin-user'], ); }); - test('deduplicates one unhealthy period and allows a later failure after recovery', async () => { + test('deduplicates system failures across instances and dismisses them on recovery', async () => { const unhealthy = { timestamp: iso(), api: 'healthy', redis: 'disconnected', daemon: 'running', worker: 'running', githubAuth: 'connected', githubEventIntakeStatus: 'active', @@ -388,25 +346,46 @@ describe('notification lifecycle projection', { concurrency: false }, () => { warnings: [{ message: 'SECRET SYSTEM ERROR' }], }; await projection.projectSystemSnapshot(unhealthy); + const secondProjection = new NotificationProjectionService({ + database, + notificationService: new NotificationService({ database, now: () => new Date(clock) }), + now: () => new Date(clock), + }); clock += 1_000; - await projection.projectSystemSnapshot({ ...unhealthy, timestamp: iso() }); + await secondProjection.projectSystemSnapshot({ ...unhealthy, timestamp: iso() }); await projection.projectSystemSnapshot({ ...unhealthy, timestamp: new Date(clock - 2_000).toISOString(), redis: 'connected', }); clock += 1_000; - await projection.projectSystemSnapshot({ ...unhealthy, timestamp: iso(), redis: 'connected' }); + await secondProjection.projectSystemSnapshot({ + ...unhealthy, timestamp: iso(), redis: 'connected', + }); + + let events = await database('notification_events').where({ kind: 'system_failure' }); + assert.equal(events.length, 1); + assert.equal( + await countUndismissedNotificationReceipts(database, 'system_failure'), + 0, + 'healthy recovery closes the active card', + ); + clock += 1_000; await projection.projectSystemSnapshot({ ...unhealthy, timestamp: iso() }); - const events = await database('notification_events').where({ kind: 'system_failure' }); + events = await database('notification_events').where({ kind: 'system_failure' }); assert.equal(events.length, 2); assert.doesNotMatch(JSON.stringify(events), /SECRET SYSTEM ERROR/); assert.deepEqual( await database('notification_user_states').distinct('user_id').pluck('user_id'), ['admin-user'], ); + assert.equal( + await countUndismissedNotificationReceipts(database, 'system_failure'), + 1, + ); + secondProjection.close(); }); test('logs and isolates projection persistence failures', async () => { @@ -417,6 +396,9 @@ describe('notification lifecycle projection', { concurrency: false }, () => { createNotificationEvent: async () => { throw new Error('database unavailable'); }, + createPullRequestAttentionNotificationEvent: async () => null, + createPullRequestNotificationEvent: async () => null, + reconcileSystemFailureTransition: async () => ({ accepted: true, event: null }), }, logger: { warn: message => warnings.push(message) }, }); diff --git a/packages/api/test/notificationProjectionTestHarness.ts b/packages/api/test/notificationProjectionTestHarness.ts new file mode 100644 index 000000000..f6fdeee52 --- /dev/null +++ b/packages/api/test/notificationProjectionTestHarness.ts @@ -0,0 +1,110 @@ +import knex, { type Knex } from 'knex'; +import { NotificationService } from '@propr/core'; +import { up as createNotificationSchema } from '../../core/src/db/migrations/20260802000000_create_notification_schema.js'; +import { up as addNotificationPreferenceApis } from '../../core/src/db/migrations/20260802010000_add_notification_preference_apis.js'; +import { up as addAdvertisedActions } from '../../core/src/db/migrations/20260824020000_add_notification_advertised_actions.js'; +import { up as addSystemFailureState } from '../../core/src/db/migrations/20260829000000_add_notification_system_failure_state.js'; +import { up as addPullRequestState } from '../../core/src/db/migrations/20260829010000_add_notification_pull_request_state.js'; +import { NotificationProjectionService } from '../services/notificationProjectionService.js'; + +export interface NotificationProjectionTestHarness { + database: Knex; + projection: NotificationProjectionService; +} + +export interface ActiveNotificationReceipt { + user_id: string; + occurred_at: string; +} + +async function createProjectionTables(database: Knex): Promise { + await database.schema.createTable('tasks', table => { + table.text('task_id').primary(); + table.text('repository').notNullable(); + table.integer('issue_number').nullable(); + table.integer('pr_number').nullable(); + table.text('task_type').notNullable(); + table.text('initial_job_data').nullable(); + }); + await database.schema.createTable('task_history', table => { + table.increments('history_id').primary(); + table.text('task_id').notNullable(); + table.text('state').notNullable(); + table.text('timestamp').notNullable(); + table.text('metadata').nullable(); + }); + await database.schema.createTable('task_drafts', table => { + table.text('draft_id').primary(); + table.text('user_id').notNullable(); + table.text('repository').notNullable(); + }); + await database.schema.createTable('instance_members', table => { + table.text('github_user_id').primary(); + table.text('role').notNullable(); + }); +} + +export async function createNotificationProjectionTestHarness( + now: () => Date, +): Promise { + const database = knex({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + pool: { + afterCreate(connection: { pragma(statement: string): void }, done: (error: Error | null, connection: unknown) => void) { + connection.pragma('foreign_keys = ON'); + connection.pragma('recursive_triggers = ON'); + done(null, connection); + }, + }, + }); + await createProjectionTables(database); + await createNotificationSchema(database); + await addNotificationPreferenceApis(database); + await addAdvertisedActions(database); + await addSystemFailureState(database); + await addPullRequestState(database); + const projection = new NotificationProjectionService({ + database, + notificationService: new NotificationService({ database, now }), + now, + stalledAfterMs: 10_000, + }); + await database('instance_members').insert([ + { github_user_id: 'admin-user', role: 'admin' }, + { github_user_id: 'member-user', role: 'member' }, + ]); + return { database, projection }; +} + +export async function listActiveNotificationReceipts( + database: Knex, + kind: string, +): Promise { + return database('notification_user_states as receipt') + .join('notification_events as event', 'event.event_id', 'receipt.event_id') + .where({ 'event.kind': kind, 'receipt.inbox_enabled': true }) + .whereNull('receipt.dismissed_at') + .select('receipt.user_id', 'event.occurred_at'); +} + +export async function countNotificationEvents(database: Knex): Promise { + return database('notification_events') + .count('* as count') + .first() + .then(row => Number(row?.count ?? 0)); +} + +export async function countUndismissedNotificationReceipts( + database: Knex, + kind: string, +): Promise { + return database('notification_user_states as receipt') + .join('notification_events as event', 'event.event_id', 'receipt.event_id') + .where({ 'event.kind': kind }) + .whereNull('receipt.dismissed_at') + .count('* as count') + .first() + .then(row => Number(row?.count ?? 0)); +} diff --git a/packages/api/test/notificationRoutes.test.ts b/packages/api/test/notificationRoutes.test.ts index a60b068b0..86045cbaa 100644 --- a/packages/api/test/notificationRoutes.test.ts +++ b/packages/api/test/notificationRoutes.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ import assert from 'node:assert/strict'; import { createECDH } from 'node:crypto'; import { after, describe, test } from 'node:test'; @@ -62,6 +63,7 @@ function createService(overrides: Partial = {}): Notif getUnreadNotificationCount: async () => 0, markNotificationRead: async () => null, dismissNotification: async () => null, + dismissAllNotifications: async () => ({ unreadCount: 0 }), getNotificationPreferences: async () => preferences, updateNotificationPreferences: async () => preferences, upsertPushSubscription: async () => parsePushSubscription({ @@ -158,6 +160,27 @@ describe('notification routes', () => { assert.equal(status(), 404); }); + test('dismisses all receipts for the authenticated user', async () => { + let receivedUserId: string | undefined; + const routes = createNotificationRoutes({ + service: createService({ + dismissAllNotifications: async userId => { + receivedUserId = userId; + return { unreadCount: 0 }; + } + }) + }); + const { response, status, body } = responseRecorder(); + + await routes.dismissAll(authenticatedRequest({ + body: { userId: 'victim-user' } + }), response); + + assert.equal(receivedUserId, 'authenticated-user'); + assert.equal(status(), 200); + assert.deepEqual(body(), { unreadCount: 0 }); + }); + test('returns 400 for malformed limits, cursors, and history flags', async () => { let calls = 0; const routes = createNotificationRoutes({ diff --git a/packages/api/test/routeAuthorization.test.ts b/packages/api/test/routeAuthorization.test.ts index 246354917..6c314ac3c 100644 --- a/packages/api/test/routeAuthorization.test.ts +++ b/packages/api/test/routeAuthorization.test.ts @@ -26,7 +26,9 @@ function handlerCollection(): never { function createAuthorizationTestApp() { const app = express(); app.use((req, _res, next) => { - const admin = req.header('x-test-role') === 'admin'; + const role = req.header('x-test-role'); + const admin = role === 'admin'; + const demo = role === 'demo'; req.authorization = { role: admin ? 'admin' : 'member', permissions: admin @@ -37,7 +39,7 @@ function createAuthorizationTestApp() { 'instance.manage_settings', ] : [], - source: admin ? 'local' : 'implicit', + source: admin ? 'local' : demo ? 'demo' : 'implicit', }; next(); }); @@ -76,6 +78,9 @@ async function withServer( const managementRequests = [ ['GET', '/api/config/settings'], ['GET', '/api/config/agents'], + ['GET', '/api/config/synthetic-agents'], + ['POST', '/api/config/synthetic-agents'], + ['GET', '/api/config/agent-tank/usage'], ['GET', '/api/admin/members'], ['GET', '/api/agent-runtime/packages'], ['POST', '/api/agent-runtime/packages/verify'], @@ -101,7 +106,7 @@ describe('assembled instance permission routes', () => { test('members can read only the sanitized catalog endpoints', async () => { await withServer(async origin => { - for (const path of ['/api/catalog', '/api/repositories/indexing-status']) { + for (const path of ['/api/catalog', '/api/instance/catalog', '/api/repositories/indexing-status']) { const response = await fetch(`${origin}${path}`); assert.equal(response.status, 200, path); } @@ -127,4 +132,17 @@ describe('assembled instance permission routes', () => { } }); }); + + test('demo users can read only the synthetic Agent Tank usage feed', async () => { + await withServer(async origin => { + const headers = { 'x-test-role': 'demo' }; + const usageResponse = await fetch(`${origin}/api/config/agent-tank/usage`, { headers }); + assert.equal(usageResponse.status, 200); + + for (const path of ['/api/config/agent-tank', '/api/config/agent-tank/status']) { + const response = await fetch(`${origin}${path}`, { headers }); + assert.equal(response.status, 403, path); + } + }); + }); }); diff --git a/packages/api/test/statusRoutes.test.ts b/packages/api/test/statusRoutes.test.ts index 07484d1c7..b1c37a15b 100644 --- a/packages/api/test/statusRoutes.test.ts +++ b/packages/api/test/statusRoutes.test.ts @@ -10,11 +10,13 @@ import { PROPR_VERSION, parseProprDesktopDiscovery, } from '@propr/shared'; +import type { SyntheticAgentConfig } from '@propr/shared'; type StatusRoutesDeps = { redisClient: RedisClientType; agentRegistry?: StatusAgentRegistry; loadAgents?: () => Promise; + loadSyntheticAgents?: () => Promise; getIndexingQueue?: () => Promise<{ getJobCounts: (...statuses: string[]) => Promise> }>; agentStatusCacheTtlMs?: number; agentHealthTimeoutMs?: number; @@ -392,6 +394,48 @@ test('/api/status caches agent health checks briefly', async () => { assert.deepEqual(first.body().agents, second.body().agents); }); +test('/api/status marks an unavailable synthetic pool degraded without downgrading direct agents', async () => { + const direct = createAgentConfig(); + const syntheticConfig: SyntheticAgentConfig = { + id: '11111111-1111-4111-8111-111111111111', + alias: 'balanced-pool', + enabled: true, + defaultModel: 'balanced', + models: [{ + id: 'balanced', + enabled: true, + strategy: 'round_robin', + members: [{ + id: '22222222-2222-4222-8222-222222222222', + directAgentAlias: direct.alias, + model: direct.supportedModels[0], + enabled: true, + priority: 100, + }], + }], + }; + const syntheticFacade = createAgent({ + ...direct, + id: syntheticConfig.id, + alias: syntheticConfig.alias, + supportedModels: ['balanced'], + defaultModel: 'balanced', + }, async () => false); + const body = await readStatus({ + loadAgents: async () => [direct], + loadSyntheticAgents: async () => [syntheticConfig], + agentRegistry: createRegistry([ + createAgent(direct, async () => true), + syntheticFacade, + ]), + }); + + assert.deepEqual(body.agents, [ + { id: direct.id, type: direct.type, alias: direct.alias, status: 'connected' }, + { id: syntheticConfig.id, type: 'synthetic', alias: syntheticConfig.alias, status: 'degraded' }, + ]); +}); + test('/api/status reports resolved auth mode and event intake mode', async () => { const body = await readStatus(); diff --git a/packages/api/test/syntheticAgentContracts.test.ts b/packages/api/test/syntheticAgentContracts.test.ts new file mode 100644 index 000000000..1bd9ed00c --- /dev/null +++ b/packages/api/test/syntheticAgentContracts.test.ts @@ -0,0 +1,163 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import type { AgentConfig } from '@propr/core'; +import { + findSyntheticReferencesToDirectAgent, + parseSyntheticAgentConfigs, + syntheticAgentConfigsSchema, + validateSyntheticAgentReferences, + type SyntheticAgentConfig, +} from '@propr/shared'; + +const AGENT_ID = '11111111-1111-4111-8111-111111111111'; +const MEMBER_ID = '22222222-2222-4222-8222-222222222222'; +const SECOND_MEMBER_ID = '33333333-3333-4333-8333-333333333333'; + +function directAgent(overrides: Partial = {}): AgentConfig { + return { + id: 'direct-agent-id', + type: 'codex', + alias: 'codex-primary', + enabled: true, + dockerImage: 'propr/agent:test', + configPath: '/tmp/codex-primary', + supportedModels: ['gpt-5.6-sol'], + defaultModel: 'gpt-5.6-sol', + ...overrides, + }; +} + +function syntheticAgent(): SyntheticAgentConfig { + return { + id: AGENT_ID, + alias: 'balanced-pool', + enabled: true, + defaultModel: 'balanced', + models: [{ + id: 'balanced', + displayName: 'Balanced', + enabled: true, + strategy: 'round_robin', + members: [{ + id: MEMBER_ID, + directAgentAlias: 'codex-primary', + model: 'gpt-5.6-sol', + enabled: true, + priority: 100, + usageLimits: { sessionMaxPercent: 80, weeklyMaxPercent: 90 }, + }], + }], + }; +} + +function cloneConfig(): SyntheticAgentConfig[] { + return structuredClone([syntheticAgent()]); +} + +function schemaError(value: unknown): string { + const result = syntheticAgentConfigsSchema.safeParse(value); + assert.equal(result.success, false); + return result.success ? '' : result.error.issues.map(issue => issue.message).join('; '); +} + +describe('synthetic agent contracts', () => { + test('parses defaults while preserving model and member order', () => { + const raw = cloneConfig() as Array>; + const agent = raw[0]; + delete agent.enabled; + const models = agent.models as Array>; + delete models[0].enabled; + delete models[0].strategy; + const firstMember = (models[0].members as Array>)[0]; + delete firstMember.enabled; + delete firstMember.priority; + models[0].members = [ + firstMember, + { + id: SECOND_MEMBER_ID, + directAgentAlias: 'codex-secondary', + model: 'gpt-5.6-sol', + }, + ]; + + const parsed = parseSyntheticAgentConfigs(raw); + + assert.equal(parsed[0].enabled, true); + assert.equal(parsed[0].models[0].strategy, 'round_robin'); + assert.deepEqual(parsed[0].models[0].members.map(member => member.id), [MEMBER_ID, SECOND_MEMBER_ID]); + assert.deepEqual(parsed[0].models[0].members.map(member => member.priority), [100, 100]); + }); + + test('rejects malformed aliases, model IDs, defaults, priorities, percentages, and duplicates', () => { + const cases: Array<[string, (value: SyntheticAgentConfig[]) => void, RegExp]> = [ + ['alias', value => { value[0].alias = 'Bad Alias'; }, /lowercase letters/], + ['model ID', value => { value[0].models[0].id = 'bad/model'; }, /Synthetic model IDs/], + ['default', value => { value[0].defaultModel = 'missing'; }, /missing or disabled/], + ['priority', value => { value[0].models[0].members[0].priority = 101; }, /Too big/], + ['percentage', value => { value[0].models[0].members[0].usageLimits.sessionMaxPercent = 0; }, /Too small/], + ['member ID', value => { + value[0].models[0].members.push({ + ...value[0].models[0].members[0], + directAgentAlias: 'codex-secondary', + }); + }, /Duplicate synthetic member ID/], + ['physical pair', value => { + value[0].models[0].members.push({ + ...value[0].models[0].members[0], + id: SECOND_MEMBER_ID, + }); + }, /Duplicate direct member/], + ['model IDs', value => { value[0].models.push(structuredClone(value[0].models[0])); }, /Duplicate synthetic model ID/], + ['aliases', value => { value.push(structuredClone(value[0])); }, /Duplicate synthetic alias/], + ]; + + for (const [name, mutate, expected] of cases) { + const value = cloneConfig(); + mutate(value); + assert.match(schemaError(value), expected, name); + } + }); + + test('rejects duplicate top-level agent IDs at the duplicate index', () => { + const value = cloneConfig(); + value.push({ ...structuredClone(value[0]), alias: 'another-pool' }); + + const result = syntheticAgentConfigsSchema.safeParse(value); + + assert.equal(result.success, false); + if (result.success) return; + const duplicateIdIssue = result.error.issues.find(issue => + issue.message === `Duplicate synthetic agent ID '${AGENT_ID}'`, + ); + assert.deepEqual(duplicateIdIssue?.path, [1, 'id']); + }); + + test('validates the shared direct namespace and physical model references', () => { + const config = [syntheticAgent()]; + assert.deepEqual(validateSyntheticAgentReferences(config, [directAgent()]), { + errors: [], + warnings: [], + }); + + const collision = validateSyntheticAgentReferences(config, [ + directAgent({ alias: 'balanced-pool' }), + ]); + assert.match(collision.errors.join('; '), /conflicts with a direct agent alias/); + assert.match(collision.errors.join('; '), /unknown direct agent 'codex-primary'/); + + const idCollision = validateSyntheticAgentReferences(config, [ + directAgent({ id: AGENT_ID }), + ]); + assert.match(idCollision.errors.join('; '), /conflicts with a direct agent ID/); + + const unsupported = validateSyntheticAgentReferences(config, [ + directAgent({ supportedModels: ['gpt-other'] }), + ]); + assert.match(unsupported.errors.join('; '), /unsupported model 'codex-primary:gpt-5\.6-sol'/); + + const disabled = validateSyntheticAgentReferences(config, [directAgent({ enabled: false })]); + assert.deepEqual(disabled.errors, []); + assert.match(disabled.warnings[0], /no enabled direct members/); + assert.deepEqual(findSyntheticReferencesToDirectAgent(config, 'codex-primary'), ['balanced-pool:balanced']); + }); +}); diff --git a/packages/api/test/syntheticAgents.test.ts b/packages/api/test/syntheticAgents.test.ts new file mode 100644 index 000000000..ebcde317a --- /dev/null +++ b/packages/api/test/syntheticAgents.test.ts @@ -0,0 +1,426 @@ +import assert from 'node:assert/strict'; +import { after, describe, test } from 'node:test'; +import type { Request, Response } from 'express'; +import knex, { type Knex } from 'knex'; +import { + closeConnection, + loadSyntheticAgents, + saveSyntheticAgents, + type AgentConfig, +} from '@propr/core'; +import { + parseSyntheticAgentConfigs, + type SyntheticAgentConfig, +} from '@propr/shared'; +import { applyAgentsUpdate } from '../routes/configRoutesAgents.js'; +import { createConfigRoutes } from '../routes/configRoutes.js'; +import { createSyntheticAgentConfigRoutes } from '../routes/configRoutesSyntheticAgents.js'; +import { createInstanceCatalogRoutes } from '../routes/instanceCatalogRoutes.js'; + +after(async () => closeConnection()); + +const AGENT_ID = '11111111-1111-4111-8111-111111111111'; +const MEMBER_ID = '22222222-2222-4222-8222-222222222222'; + +function directAgent(overrides: Partial = {}): AgentConfig { + return { + id: 'direct-agent-id', + type: 'codex', + alias: 'codex-primary', + enabled: true, + dockerImage: 'propr/agent:test', + configPath: '/tmp/codex-primary', + supportedModels: ['gpt-5.6-sol'], + defaultModel: 'gpt-5.6-sol', + ...overrides, + }; +} + +function syntheticAgent(): SyntheticAgentConfig { + return { + id: AGENT_ID, + alias: 'balanced-pool', + enabled: true, + defaultModel: 'balanced', + models: [{ + id: 'balanced', + displayName: 'Balanced', + enabled: true, + strategy: 'round_robin', + members: [{ + id: MEMBER_ID, + directAgentAlias: 'codex-primary', + model: 'gpt-5.6-sol', + enabled: true, + priority: 100, + usageLimits: { sessionMaxPercent: 80, weeklyMaxPercent: 90 }, + }], + }], + }; +} + +function cloneConfig(): SyntheticAgentConfig[] { + return structuredClone([syntheticAgent()]); +} + +function responseRecorder() { + const record: { status: number; body?: unknown } = { status: 200 }; + const response = { + status(code: number) { record.status = code; return response; }, + json(body: unknown) { record.body = body; return response; }, + } as unknown as Response; + return { response, record }; +} + +function redisLockClient() { + return { + set: async () => 'OK', + eval: async () => 1, + } as never; +} + +async function createConfigDatabase(): Promise { + const database = knex({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + await database.schema.createTable('system_configs', table => { + table.string('key').primary(); + table.text('value'); + table.timestamp('created_at'); + table.timestamp('updated_at'); + }); + return database; +} + +describe('synthetic agent persistence and API', () => { + test('persists in its own config document and round-trips unchanged', async () => { + const database = await createConfigDatabase(); + try { + const original = [syntheticAgent()]; + await saveSyntheticAgents(original, database); + const saved = await loadSyntheticAgents(database); + const row = await database('system_configs').where({ key: 'synthetic_agents' }).first(); + + assert.deepEqual(saved, original); + assert.ok(row); + assert.equal(await database('system_configs').where({ key: 'agents' }).first(), undefined); + } finally { + await database.destroy(); + } + }); + + test('configuration handlers round-trip valid input and return actionable 400 errors', async () => { + let stored: SyntheticAgentConfig[] = []; + const routes = createSyntheticAgentConfigRoutes({ + redisClient: redisLockClient(), + configStore: { + loadAgents: async () => [directAgent()], + loadSettings: async () => ({}), + loadSyntheticAgents: async () => stored, + saveSyntheticAgents: async value => { + stored = parseSyntheticAgentConfigs(value); + return stored; + }, + }, + publishConfigUpdate: async () => undefined, + logActivityHelper: async () => undefined, + }); + const post = responseRecorder(); + + await routes.postSyntheticAgents({ + body: { synthetic_agents: [syntheticAgent()] }, + user: { username: 'admin' }, + } as Request, post.response); + assert.equal(post.record.status, 200); + assert.deepEqual((post.record.body as { synthetic_agents: unknown }).synthetic_agents, [syntheticAgent()]); + + const get = responseRecorder(); + await routes.getSyntheticAgents({} as Request, get.response); + assert.deepEqual(get.record.body, { synthetic_agents: [syntheticAgent()] }); + + const invalid = cloneConfig(); + invalid[0].models[0].members[0].priority = -1; + const bad = responseRecorder(); + await routes.postSyntheticAgents({ body: { synthetic_agents: invalid } } as Request, bad.response); + assert.equal(bad.record.status, 400); + assert.match((bad.record.body as { error: string }).error, /synthetic_agents\.0\.models\.0\.members\.0\.priority/); + + const unknown = cloneConfig(); + unknown[0].models[0].members[0].model = 'unknown-model'; + const unknownResponse = responseRecorder(); + await routes.postSyntheticAgents({ body: { synthetic_agents: unknown } } as Request, unknownResponse.response); + assert.equal(unknownResponse.record.status, 400); + assert.match((unknownResponse.record.body as { error: string }).error, /unsupported model/); + }); + + test('allows executable synthetic defaults but rejects removing or disabling the configured default', async () => { + const noEnabledMembers = syntheticAgent(); + noEnabledMembers.models[0].members[0].enabled = false; + const replacements: Array<[string, SyntheticAgentConfig[], SyntheticAgentConfig[], number]> = [ + ['unchanged', [syntheticAgent()], [syntheticAgent()], 200], + ['newly introduced', [], [syntheticAgent()], 200], + ['removed', [syntheticAgent()], [], 409], + ['disabled', [syntheticAgent()], [{ ...syntheticAgent(), enabled: false }], 409], + ['without an executable default model', [syntheticAgent()], [noEnabledMembers], 409], + ]; + + for (const [name, previous, replacement, expectedStatus] of replacements) { + let saved = false; + let published = false; + const routes = createSyntheticAgentConfigRoutes({ + redisClient: redisLockClient(), + configStore: { + loadAgents: async () => [directAgent()], + loadSettings: async () => ({ default_agent_alias: 'balanced-pool' }), + loadSyntheticAgents: async () => previous, + saveSyntheticAgents: async value => { + saved = true; + return parseSyntheticAgentConfigs(value); + }, + }, + publishConfigUpdate: async () => { published = true; }, + logActivityHelper: async () => undefined, + }); + const response = responseRecorder(); + + await routes.postSyntheticAgents({ + body: { synthetic_agents: replacement }, + } as Request, response.response); + + assert.equal(response.record.status, expectedStatus, name); + assert.equal(saved, expectedStatus === 200, name); + assert.equal(published, expectedStatus === 200, name); + } + + const routes = createSyntheticAgentConfigRoutes({ + redisClient: redisLockClient(), + configStore: { + loadAgents: async () => [directAgent({ enabled: false })], + loadSettings: async () => ({ default_agent_alias: 'balanced-pool' }), + loadSyntheticAgents: async () => [syntheticAgent()], + saveSyntheticAgents: async value => parseSyntheticAgentConfigs(value), + }, + publishConfigUpdate: async () => undefined, + logActivityHelper: async () => undefined, + }); + const unusableBackingAgent = responseRecorder(); + await routes.postSyntheticAgents({ + body: { synthetic_agents: [syntheticAgent()] }, + } as Request, unusableBackingAgent.response); + assert.equal(unusableBackingAgent.record.status, 409); + assert.match((unusableBackingAgent.record.body as { error: string }).error, /enabled direct agent/); + }); + + test('allows settings updates that select a synthetic default alias', async () => { + const database = await createConfigDatabase(); + let published = false; + try { + const routes = createConfigRoutes({ + redisClient: { + set: async () => 'OK', + eval: async () => 1, + publish: async () => { published = true; return 1; }, + lPush: async () => 1, + lTrim: async () => 1, + } as never, + configStore: { + loadAgents: async () => [directAgent()], + loadSettings: async () => ({}), + loadSyntheticAgents: async () => [syntheticAgent()], + }, + database, + }); + const response = responseRecorder(); + + await routes.postSettings({ + body: { settings: { default_agent_alias: ' balanced-pool ' } }, + } as Request, response.response); + + assert.equal(response.record.status, 200); + const settingsRow = await database('system_configs').where({ key: 'settings' }).first(); + assert.deepEqual(JSON.parse(settingsRow.value), { default_agent_alias: 'balanced-pool' }); + assert.equal(published, true); + } finally { + await database.destroy(); + } + }); + + test('rejects selecting a synthetic default without a usable physical member', async () => { + const database = await createConfigDatabase(); + try { + const routes = createConfigRoutes({ + redisClient: redisLockClient() as never, + configStore: { + loadAgents: async () => [directAgent({ enabled: false })], + loadSettings: async () => ({}), + loadSyntheticAgents: async () => [syntheticAgent()], + }, + database, + }); + const response = responseRecorder(); + + await routes.postSettings({ + body: { settings: { default_agent_alias: 'balanced-pool' } }, + } as Request, response.response); + + assert.equal(response.record.status, 409); + assert.match((response.record.body as { error: string }).error, /no enabled member backed by an enabled direct agent/); + assert.equal(await database('system_configs').where({ key: 'settings' }).first(), undefined); + } finally { + await database.destroy(); + } + }); +}); + +describe('synthetic direct-agent integrity and catalog', () => { + test('preserves a synthetic default during a direct-agent update', async () => { + const database = await createConfigDatabase(); + const previous = directAgent(); + const updated = { ...previous, configPath: '/tmp/codex-primary-updated' }; + const publishedUpdates: string[] = []; + let appliedDefault: string | null | undefined; + try { + const result = await applyAgentsUpdate({ + agents: [updated], + processedAgents: [updated], + username: 'admin', + publishConfigUpdate: async subtype => { publishedUpdates.push(subtype); }, + logActivityHelper: async () => undefined, + configStore: { + loadAgents: async () => [previous], + loadSyntheticAgents: async () => [syntheticAgent()], + loadSettings: async () => ({ default_agent_alias: 'balanced-pool' }), + handleSettingsSaveSideEffects: async () => undefined, + }, + database, + registry: { + refresh: async () => undefined, + setDefaultAgentAlias: alias => { appliedDefault = alias; }, + }, + }); + + assert.equal(result.status, 200); + assert.equal(appliedDefault, 'balanced-pool'); + assert.deepEqual(publishedUpdates, ['agents_update']); + const settingsRow = await database('system_configs').where({ key: 'settings' }).first(); + assert.equal(settingsRow, undefined); + } finally { + await database.destroy(); + } + }); + + test('blocks deletion of a referenced direct alias and disabling the last member of a synthetic default', async () => { + const database = await createConfigDatabase(); + const previous = directAgent(); + const configStore = { + loadAgents: async () => [previous], + loadSyntheticAgents: async () => [syntheticAgent()], + loadSettings: async () => ({ default_agent_alias: 'balanced-pool' }), + handleSettingsSaveSideEffects: async () => undefined, + }; + const common = { + username: 'admin', + publishConfigUpdate: async () => undefined, + logActivityHelper: async () => undefined, + configStore, + database, + registry: { + refresh: async () => undefined, + setDefaultAgentAlias: () => undefined, + }, + }; + try { + const deletion = await applyAgentsUpdate({ + agents: [], + processedAgents: [], + ...common, + }); + assert.equal(deletion.status, 409); + assert.match((deletion.body as { error: string }).error, /balanced-pool:balanced/); + + const disabled = { ...previous, enabled: false }; + const disable = await applyAgentsUpdate({ + agents: [disabled], + processedAgents: [disabled], + ...common, + }); + assert.equal(disable.status, 409); + assert.match((disable.body as { error: string }).error, /no enabled member backed by an enabled direct agent/); + } finally { + await database.destroy(); + } + }); + + test('projects enabled synthetic agents and models only in the instance catalog', async () => { + const config = syntheticAgent(); + config.models.push({ + ...structuredClone(config.models[0]), + id: 'disabled-model', + enabled: false, + }); + let syntheticLoads = 0; + const routes = createInstanceCatalogRoutes({ + services: { + loadAgents: async () => [ + directAgent(), + directAgent({ id: 'disabled', alias: 'codex-disabled', enabled: false }), + ], + loadSyntheticAgents: async () => { + syntheticLoads += 1; + return [ + config, + { ...syntheticAgent(), id: '44444444-4444-4444-8444-444444444444', alias: 'disabled-pool', enabled: false }, + ]; + }, + loadRepositories: async () => [], + loadSettings: async () => ({ default_agent_alias: 'balanced-pool' }), + }, + }); + const { response, record } = responseRecorder(); + + await routes.getCatalog({} as Request, response); + + assert.deepEqual(record.body, { + agents: [ + { + id: 'direct-agent-id', + kind: 'direct', + alias: 'codex-primary', + enabled: true, + supportedModels: ['gpt-5.6-sol'], + defaultModel: 'gpt-5.6-sol', + }, + { + id: AGENT_ID, + kind: 'synthetic', + alias: 'balanced-pool', + enabled: true, + supportedModels: ['balanced'], + defaultModel: 'balanced', + }, + ], + repositories: [], + defaultAgentAlias: 'balanced-pool', + }); + + const legacy = responseRecorder(); + await routes.getLegacyCatalog({} as Request, legacy.response); + + assert.deepEqual(legacy.record.body, { + agents: [ + { + id: 'direct-agent-id', + kind: 'direct', + alias: 'codex-primary', + enabled: true, + supportedModels: ['gpt-5.6-sol'], + defaultModel: 'gpt-5.6-sol', + }, + ], + repositories: [], + }); + assert.equal(syntheticLoads, 1); + }); +}); diff --git a/packages/api/test/webPushDispatcher.test.ts b/packages/api/test/webPushDispatcher.test.ts index 97b67354e..41cd78f34 100644 --- a/packages/api/test/webPushDispatcher.test.ts +++ b/packages/api/test/webPushDispatcher.test.ts @@ -68,7 +68,7 @@ function createDatabase(): Knex { } function vapidConfiguration() { - // A generated scalar can lose leading zero bytes when exported; keep this fixture full-width. + // Keep the fixture full-width because getPrivateKey() can omit leading zero bytes. const privateKey = Buffer.alloc(32); privateKey[31] = 1; const ecdh = createECDH('prime256v1'); @@ -309,14 +309,21 @@ describe('Web Push dispatcher', { concurrency: false }, () => { }); test('paginates past a quiet-hour prefix larger than the scan window', async () => { + const fixtureBaseTime = HISTORICAL_FIXTURE_TIME; + let fixtureTick = 0; + const fixtureService = new NotificationService({ + database, + now: () => new Date(fixtureBaseTime + fixtureTick++), + }); const quietUsers: string[] = []; for (let index = 0; index < 21; index += 1) { const queued = await queuedEvent({ + service: fixtureService, quietHours: { start: '00:00', end: '23:59', timezone: 'UTC' }, }); quietUsers.push(queued.userId); } - const eligible = await queuedEvent(); + const eligible = await queuedEvent({ service: fixtureService }); const dispatchAt = dispatchFixtureTime(); const currentMinute = dispatchAt.getUTCHours() * 60 + dispatchAt.getUTCMinutes(); const formatMinute = (minute: number) => { @@ -339,6 +346,7 @@ describe('Web Push dispatcher', { concurrency: false }, () => { }, }, { batchSize: 1, + leaseMs: 30_000, now: () => dispatchAt, }); @@ -595,15 +603,17 @@ describe('Web Push dispatcher', { concurrency: false }, () => { test('skips network I/O when the claim expires during delivery preparation', async () => { await queuedEvent(); - const baseTime = DISPATCH_FIXTURE_TIME - 4_000; + const baseTime = DISPATCH_FIXTURE_TIME - 1_000; + const leaseMs = 30_000; let nowCalls = 0; let sends = 0; const worker = dispatcher({ sendNotification: async () => { sends += 1; return success; }, }, { - leaseMs: 5_000, - requestTimeoutMs: 4_999, - now: () => new Date(baseTime + nowCalls++ * 2_000), + leaseMs, + requestTimeoutMs: leaseMs - 1, + // Keep the initial claim ahead of SQLite's fixture clock, then expire it before renewal. + now: () => new Date(baseTime + (nowCalls++ >= 3 ? leaseMs + 1_000 : 0)), }); assert.equal(await worker.runOnce(), 1); diff --git a/packages/cli/src/api/index.ts b/packages/cli/src/api/index.ts index 0ee26f281..050dd105e 100644 --- a/packages/cli/src/api/index.ts +++ b/packages/cli/src/api/index.ts @@ -157,6 +157,18 @@ export type { SaveAgentsResponse, } from "./agents.js"; +// Synthetic agent pools configuration API +export { + listSyntheticAgents, + saveSyntheticAgents, + deleteSyntheticAgent, +} from "./syntheticPools.js"; + +export type { + SyntheticAgentsResponse, + SaveSyntheticAgentsResponse, +} from "./syntheticPools.js"; + // System Settings API export { getSettings, diff --git a/packages/cli/src/api/repos.test.ts b/packages/cli/src/api/repos.test.ts new file mode 100644 index 000000000..84f39dd78 --- /dev/null +++ b/packages/cli/src/api/repos.test.ts @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import type { ApiClient } from './client.js'; +import { addRepo, updateRepo, type MonitoredRepo } from './repos.js'; + +function createClient(repos: MonitoredRepo[]): { client: ApiClient; postedRepos: () => MonitoredRepo[] } { + let savedRepos: MonitoredRepo[] = []; + const client = { + get: async () => ({ data: { repos_to_monitor: repos } }), + post: async (_path: string, options: { body: { repos_to_monitor: MonitoredRepo[] } }) => { + savedRepos = options.body.repos_to_monitor; + return { data: { success: true, repos_to_monitor: savedRepos } }; + } + } as unknown as ApiClient; + return { client, postedRepos: () => savedRepos }; +} + +test('addRepo preserves existing failed-CI options and defaults the new repository to false', async () => { + const existing = { + id: 'repo-1', + name: 'integry/propr', + enabled: true, + autoFollowupOnFailedCi: true + }; + const { client, postedRepos } = createClient([existing]); + + await addRepo('integry/other', {}, client); + + assert.equal(postedRepos()[0]?.autoFollowupOnFailedCi, true); + assert.equal(postedRepos()[1]?.autoFollowupOnFailedCi, false); +}); + +test('updateRepo writes the failed-CI option without changing other repositories', async () => { + const { client, postedRepos } = createClient([ + { id: 'repo-1', name: 'integry/propr', enabled: true, autoFollowupOnFailedCi: false }, + { id: 'repo-2', name: 'integry/other', enabled: true, autoFollowupOnFailedCi: false } + ]); + + await updateRepo('integry/propr', { autoFollowupOnFailedCi: true }, client); + + assert.equal(postedRepos()[0]?.autoFollowupOnFailedCi, true); + assert.equal(postedRepos()[1]?.autoFollowupOnFailedCi, false); +}); diff --git a/packages/cli/src/api/repos.ts b/packages/cli/src/api/repos.ts index d0c98afb8..149f07750 100644 --- a/packages/cli/src/api/repos.ts +++ b/packages/cli/src/api/repos.ts @@ -166,6 +166,11 @@ export interface MonitoredRepo { */ enabled: boolean; + /** + * Whether failed CI should trigger automatic follow-up work for this repository. + */ + autoFollowupOnFailedCi: boolean; + /** * Optional display alias for the repository. */ @@ -205,6 +210,11 @@ export interface AddRepoOptions { * Whether monitoring is enabled. Defaults to true. */ enabled?: boolean; + + /** + * Whether failed CI should trigger automatic follow-up work. Defaults to false. + */ + autoFollowupOnFailedCi?: boolean; } /** @@ -225,6 +235,11 @@ export interface UpdateRepoOptions { * Optional new enabled state. */ enabled?: boolean; + + /** + * Optional new automatic failed-CI follow-up state. + */ + autoFollowupOnFailedCi?: boolean; } /** @@ -308,6 +323,7 @@ export async function addRepo( id: crypto.randomUUID(), name: fullName, enabled: options.enabled ?? true, + autoFollowupOnFailedCi: options.autoFollowupOnFailedCi ?? false, alias: options.alias?.trim() || undefined, baseBranch: options.baseBranch?.trim() || undefined, }; @@ -366,6 +382,7 @@ export async function updateRepo( const updatedRepo: MonitoredRepo = { ...existingRepo, ...(updates.enabled !== undefined && { enabled: updates.enabled }), + ...(updates.autoFollowupOnFailedCi !== undefined && { autoFollowupOnFailedCi: updates.autoFollowupOnFailedCi }), ...(updates.alias !== undefined && { alias: updates.alias?.trim() || undefined }), ...(updates.baseBranch !== undefined && { baseBranch: updates.baseBranch?.trim() || undefined }), }; diff --git a/packages/cli/src/api/syntheticPools.test.ts b/packages/cli/src/api/syntheticPools.test.ts new file mode 100644 index 000000000..4ae043cbd --- /dev/null +++ b/packages/cli/src/api/syntheticPools.test.ts @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { SyntheticAgentConfig } from "@propr/shared"; +import type { ApiClient } from "./client.js"; +import { + deleteSyntheticAgent, + listSyntheticAgents, + saveSyntheticAgents, +} from "./syntheticPools.js"; + +const pool: SyntheticAgentConfig = { + id: "11111111-1111-4111-8111-111111111111", + alias: "pool", + enabled: true, + defaultModel: "virtual", + models: [{ + id: "virtual", + enabled: true, + strategy: "round_robin", + members: [{ + id: "22222222-2222-4222-8222-222222222222", + directAgentAlias: "codex-a", + model: "gpt-5.6-sol", + enabled: true, + priority: 100, + }], + }], +}; + +test("synthetic pool helpers use the complete configuration endpoint", async () => { + const calls: Array<{ method: string; endpoint: string; options?: unknown }> = []; + const client = { + async get(endpoint: string) { + calls.push({ method: "GET", endpoint }); + return { data: { synthetic_agents: [pool] }, status: 200, headers: new Headers() }; + }, + async post(endpoint: string, options?: unknown) { + calls.push({ method: "POST", endpoint, options }); + return { data: { success: true, synthetic_agents: [] }, status: 200, headers: new Headers() }; + }, + } as unknown as ApiClient; + + assert.deepEqual(await listSyntheticAgents(client), { synthetic_agents: [pool] }); + await saveSyntheticAgents([pool], client); + await deleteSyntheticAgent("pool", client); + + assert.deepEqual(calls, [ + { method: "GET", endpoint: "/api/config/synthetic-agents" }, + { method: "POST", endpoint: "/api/config/synthetic-agents", options: { body: { synthetic_agents: [pool] } } }, + { method: "GET", endpoint: "/api/config/synthetic-agents" }, + { method: "POST", endpoint: "/api/config/synthetic-agents", options: { body: { synthetic_agents: [] } } }, + ]); +}); + +test("delete rejects a selector that matches different pools by ID and alias", async () => { + const aliasCollision: SyntheticAgentConfig = { + ...pool, + id: "33333333-3333-4333-8333-333333333333", + alias: pool.id, + }; + let postCalls = 0; + const client = { + async get() { + return { data: { synthetic_agents: [pool, aliasCollision] }, status: 200, headers: new Headers() }; + }, + async post() { + postCalls += 1; + return { data: { success: true, synthetic_agents: [] }, status: 200, headers: new Headers() }; + }, + } as unknown as ApiClient; + + await assert.rejects( + deleteSyntheticAgent(pool.id, client), + new RegExp(`selector '${pool.id}' is ambiguous`) + ); + assert.equal(postCalls, 0); +}); diff --git a/packages/cli/src/api/syntheticPools.ts b/packages/cli/src/api/syntheticPools.ts new file mode 100644 index 000000000..907b1f603 --- /dev/null +++ b/packages/cli/src/api/syntheticPools.ts @@ -0,0 +1,58 @@ +/** Typed helpers for the synthetic-agent configuration endpoint. */ + +import type { SyntheticAgentConfig } from "@propr/shared"; +import { ApiClient, createApiClient } from "./client.js"; + +export interface SyntheticAgentsResponse { + synthetic_agents: SyntheticAgentConfig[]; +} + +export interface SaveSyntheticAgentsResponse extends SyntheticAgentsResponse { + success: boolean; + warnings?: string[]; + committed?: boolean; +} + +/** Lists the complete synthetic configuration document. */ +export async function listSyntheticAgents( + client?: ApiClient +): Promise { + const apiClient = client ?? (await createApiClient()); + return (await apiClient.get( + "/api/config/synthetic-agents" + )).data; +} + +/** Replaces the complete synthetic configuration document. */ +export async function saveSyntheticAgents( + syntheticAgents: SyntheticAgentConfig[], + client?: ApiClient +): Promise { + const apiClient = client ?? (await createApiClient()); + return (await apiClient.post( + "/api/config/synthetic-agents", + { body: { synthetic_agents: syntheticAgents } } + )).data; +} + +/** Deletes one synthetic agent by its stable ID or alias. */ +export async function deleteSyntheticAgent( + idOrAlias: string, + client?: ApiClient +): Promise { + const apiClient = client ?? (await createApiClient()); + const current = await listSyntheticAgents(apiClient); + const idMatch = current.synthetic_agents.find((pool) => pool.id === idOrAlias); + const aliasMatch = current.synthetic_agents.find((pool) => pool.alias === idOrAlias); + if (idMatch && aliasMatch && idMatch.id !== aliasMatch.id) { + throw new Error( + `Synthetic pool selector '${idOrAlias}' is ambiguous: it matches the ID of '${idMatch.alias}' and the alias of pool '${aliasMatch.id}'. Use a non-conflicting ID or alias.` + ); + } + const match = idMatch ?? aliasMatch; + if (!match) throw new Error(`Synthetic pool '${idOrAlias}' not found`); + return saveSyntheticAgents( + current.synthetic_agents.filter((pool) => pool.id !== match.id), + apiClient + ); +} diff --git a/packages/cli/src/commands/agentCommands.ts b/packages/cli/src/commands/agentCommands.ts index ee177aaa0..d928e5ffd 100644 --- a/packages/cli/src/commands/agentCommands.ts +++ b/packages/cli/src/commands/agentCommands.ts @@ -30,6 +30,7 @@ import { JsonInputError, } from "../utils/index.js"; import { presentApiError } from "../utils/apiErrorPresentation.js"; +import { createAgentPoolCommand } from "./agentPoolCommands.js"; const AGENT_TYPE_LIST = AGENT_TYPES.join(", "); @@ -140,6 +141,8 @@ Examples: $ propr agent delete my-agent # Delete an agent `); + agent.addCommand(createAgentPoolCommand()); + // agent list agent .command("list") diff --git a/packages/cli/src/commands/agentPoolCommands.test.ts b/packages/cli/src/commands/agentPoolCommands.test.ts new file mode 100644 index 000000000..d6693e910 --- /dev/null +++ b/packages/cli/src/commands/agentPoolCommands.test.ts @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createAgentCommand } from "./agentCommands.js"; + +const originalFetch = globalThis.fetch; +const originalLog = console.log; +const originalError = console.error; +const originalHome = process.env.HOME; +const originalExitCode = process.exitCode; + +afterEach(() => { + globalThis.fetch = originalFetch; + console.log = originalLog; + console.error = originalError; + process.exitCode = originalExitCode; + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; +}); + +const document = { + synthetic_agents: [{ + id: "11111111-1111-4111-8111-111111111111", + alias: "balanced-pool", + enabled: true, + defaultModel: "balanced", + models: [{ + id: "balanced", + enabled: true, + strategy: "round_robin", + members: [{ + id: "22222222-2222-4222-8222-222222222222", + directAgentAlias: "codex-a", + model: "gpt-5.6-sol", + enabled: true, + priority: 100, + }], + }], + }], +}; + +test("pool list JSON can be passed unchanged to pool apply", async () => { + const temporaryHome = await mkdtemp(join(tmpdir(), "propr-pool-command-")); + const file = join(temporaryHome, "pools.json"); + const stdout: string[] = []; + const requests: Array<{ method: string; body?: unknown }> = []; + process.env.HOME = temporaryHome; + console.log = (...values: unknown[]) => stdout.push(values.map(String).join(" ")); + console.error = () => undefined; + globalThis.fetch = (async (_input, init) => { + const method = init?.method ?? "GET"; + requests.push({ + method, + ...(typeof init?.body === "string" ? { body: JSON.parse(init.body) } : {}), + }); + const body = method === "GET" ? document : { success: true, ...document }; + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + + try { + await createAgentCommand().parseAsync(["pool", "list", "--json"], { from: "user" }); + assert.equal(stdout.length, 1); + assert.deepEqual(JSON.parse(stdout[0]), document); + await writeFile(file, stdout[0], "utf8"); + + stdout.length = 0; + await createAgentCommand().parseAsync(["pool", "apply", file, "--json"], { from: "user" }); + + assert.deepEqual(requests, [ + { method: "GET" }, + { method: "POST", body: document }, + ]); + assert.deepEqual(JSON.parse(stdout[0]), { success: true, ...document }); + } finally { + await rm(temporaryHome, { recursive: true, force: true }); + } +}); + +test("pool apply preserves backend nested validation messages", async () => { + const temporaryHome = await mkdtemp(join(tmpdir(), "propr-pool-error-")); + const file = join(temporaryHome, "pools.json"); + const stderr: string[] = []; + process.env.HOME = temporaryHome; + await writeFile(file, JSON.stringify(document), "utf8"); + console.log = () => undefined; + console.error = (...values: unknown[]) => stderr.push(values.map(String).join(" ")); + globalThis.fetch = (async () => new Response(JSON.stringify({ + error: "synthetic_agents.0.models.0.members.0.priority: Number must be greater than or equal to 0", + }), { status: 400, headers: { "content-type": "application/json" } })) as typeof fetch; + + try { + await createAgentCommand().parseAsync(["pool", "apply", file], { from: "user" }); + assert.match(stderr.join("\n"), /synthetic_agents\.0\.models\.0\.members\.0\.priority: Number must be greater than or equal to 0/); + assert.equal(process.exitCode, 1); + } finally { + await rm(temporaryHome, { recursive: true, force: true }); + } +}); diff --git a/packages/cli/src/commands/agentPoolCommands.ts b/packages/cli/src/commands/agentPoolCommands.ts new file mode 100644 index 000000000..12a7d3314 --- /dev/null +++ b/packages/cli/src/commands/agentPoolCommands.ts @@ -0,0 +1,113 @@ +import { Command } from "commander"; +import type { SyntheticAgentConfig } from "@propr/shared"; +import { + deleteSyntheticAgent, + listSyntheticAgents, + saveSyntheticAgents, + type SyntheticAgentsResponse, +} from "../api/syntheticPools.js"; +import { NetworkError } from "../api/errors.js"; +import { JsonInputError, printOutput, readJsonInput } from "../utils/io.js"; +import { presentApiError } from "../utils/apiErrorPresentation.js"; + +function poolsFromInput(value: unknown): SyntheticAgentConfig[] { + if (Array.isArray(value)) return value as SyntheticAgentConfig[]; + if (value && typeof value === "object") { + const pools = (value as Partial).synthetic_agents; + if (Array.isArray(pools)) return pools; + } + throw new JsonInputError( + "Input must be a synthetic_agents response from 'pool list --json' or an array of synthetic agents" + ); +} + +function printPoolTable(pools: SyntheticAgentConfig[]): void { + if (pools.length === 0) { + console.log("No synthetic pools configured."); + return; + } + + console.log("Alias Enabled Default model Virtual models"); + console.log("---------------------------------------------------------------------"); + for (const pool of pools) { + const models = pool.models.map((model) => model.id).join(", "); + console.log( + `${pool.alias.padEnd(21)} ${String(pool.enabled ? "Yes" : "No").padEnd(8)} ${pool.defaultModel.padEnd(21)} ${models}` + ); + } +} + +function reportPoolError(error: unknown, action: string): void { + if (error instanceof NetworkError) { + console.error("Error: cannot reach the ProPR backend. Start the stack first: propr start"); + return; + } + if (error instanceof JsonInputError) { + console.error(`Error: ${error.message}`); + return; + } + presentApiError(error, { + forbiddenMessage: "Error: Access denied. You do not have permission to manage synthetic pools.", + // Preserve the backend's nested-field validation message verbatim. + fallbackMessage: (message) => `Error ${action} synthetic pools: ${message}`, + }); +} + +export function createAgentPoolCommand(): Command { + const pool = new Command("pool") + .description("Manage synthetic agent pools") + .addHelpText("after", ` +Examples: + $ propr agent pool list + $ propr agent pool list --json > pools.json + $ propr agent pool apply pools.json + $ cat pools.json | propr agent pool apply - + $ propr agent pool delete balanced-pool +`); + + pool.command("list") + .description("List the complete synthetic pool configuration") + .option("-j, --json", "Output JSON that can be passed unchanged to pool apply") + .action(async (options: { json?: boolean }) => { + try { + const result = await listSyntheticAgents(); + if (printOutput(result, options.json ?? false)) return; + printPoolTable(result.synthetic_agents); + } catch (error) { + reportPoolError(error, "listing"); + process.exitCode = 1; + } + }); + + pool.command("apply ") + .description("Replace synthetic pools from a JSON file, or '-' for stdin") + .option("-j, --json", "Output the backend response as JSON") + .action(async (file: string, options: { json?: boolean }) => { + try { + const pools = poolsFromInput(await readJsonInput(file)); + const result = await saveSyntheticAgents(pools); + if (printOutput(result, options.json ?? false)) return; + console.log(`Applied ${result.synthetic_agents.length} synthetic pool(s).`); + for (const warning of result.warnings ?? []) console.warn(`Warning: ${warning}`); + } catch (error) { + reportPoolError(error, "applying"); + process.exitCode = 1; + } + }); + + pool.command("delete ") + .description("Delete a synthetic pool by ID or alias") + .option("-j, --json", "Output the backend response as JSON") + .action(async (idOrAlias: string, options: { json?: boolean }) => { + try { + const result = await deleteSyntheticAgent(idOrAlias); + if (printOutput(result, options.json ?? false)) return; + console.log(`Deleted synthetic pool '${idOrAlias}'.`); + } catch (error) { + reportPoolError(error, "deleting"); + process.exitCode = 1; + } + }); + + return pool; +} diff --git a/packages/cli/src/commands/connectCommand.ts b/packages/cli/src/commands/connectCommand.ts index eb1ac66b2..4d17cc92d 100644 --- a/packages/cli/src/commands/connectCommand.ts +++ b/packages/cli/src/commands/connectCommand.ts @@ -4,7 +4,7 @@ import { PROPR_CONNECT_DISCOVERY_SCHEMA_VERSION, canonicalProprProxyUrl, evaluateProprApiCompatibility, - parseProprDesktopDiscovery, + parseProprDesktopDiscoveryJson, type ProprDesktopDiscovery, } from "@propr/shared"; import { prepareConnectHostConfig } from "../orchestrator/index.js"; @@ -222,14 +222,7 @@ async function performDiscoveryFetch( } const bodyResult = await readBoundedBody(response, signal); if (bodyResult.kind !== "ok") return { kind: bodyResult.kind }; - let parsed: unknown; - try { - parsed = JSON.parse(bodyResult.body); - } catch { - cancelResponseBody(response); - return { kind: "invalid" }; - } - const discovery = parseProprDesktopDiscovery(parsed); + const discovery = parseProprDesktopDiscoveryJson(bodyResult.body); if (!discovery) cancelResponseBody(response); return discovery ? { kind: "ok", discovery } : { kind: "invalid" }; } catch { diff --git a/packages/cli/src/commands/repoCommands.test.ts b/packages/cli/src/commands/repoCommands.test.ts new file mode 100644 index 000000000..9297c8297 --- /dev/null +++ b/packages/cli/src/commands/repoCommands.test.ts @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; +import { createRepoCommand } from "./repoCommands.js"; +import type { MonitoredRepo } from "../api/repos.js"; + +const originalFetch = globalThis.fetch; +const originalConsoleLog = console.log; + +afterEach(() => { + globalThis.fetch = originalFetch; + console.log = originalConsoleLog; +}); + +async function runRepoWrite( + args: string[], + currentRepos: MonitoredRepo[] +): Promise { + let postedRepos: MonitoredRepo[] | undefined; + console.log = () => undefined; + globalThis.fetch = (async (_input, init) => { + if ((init?.method ?? "GET") === "GET") { + return new Response(JSON.stringify({ repos_to_monitor: currentRepos }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + + const body = JSON.parse(String(init?.body)) as { repos_to_monitor: MonitoredRepo[] }; + postedRepos = body.repos_to_monitor; + return new Response(JSON.stringify({ success: true, repos_to_monitor: postedRepos }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + + await createRepoCommand().parseAsync(args, { from: "user" }); + assert.ok(postedRepos, "expected repository configuration to be posted"); + return postedRepos; +} + +test("repo add enables automatic CI follow-up only when requested", async () => { + const existing: MonitoredRepo = { + id: "repo-1", + name: "integry/propr", + enabled: true, + autoFollowupOnFailedCi: true, + }; + + const enabled = await runRepoWrite( + ["add", "integry/enabled", "--auto-ci-followup"], + [existing] + ); + assert.equal(enabled[0]?.autoFollowupOnFailedCi, true); + assert.equal(enabled[1]?.autoFollowupOnFailedCi, true); + + const defaulted = await runRepoWrite(["add", "integry/defaulted"], [existing]); + assert.equal(defaulted[0]?.autoFollowupOnFailedCi, true); + assert.equal(defaulted[1]?.autoFollowupOnFailedCi, false); +}); + +test("repo toggle accepts positive and negative automatic CI follow-up flags", async () => { + const other: MonitoredRepo = { + id: "repo-2", + name: "integry/other", + enabled: true, + autoFollowupOnFailedCi: true, + }; + + const enabled = await runRepoWrite( + ["toggle", "integry/propr", "--auto-ci-followup"], + [ + { id: "repo-1", name: "integry/propr", enabled: false, autoFollowupOnFailedCi: false }, + other, + ] + ); + assert.deepEqual(enabled[0], { + id: "repo-1", + name: "integry/propr", + enabled: false, + autoFollowupOnFailedCi: true, + }); + assert.equal(enabled[1]?.autoFollowupOnFailedCi, true); + + const disabled = await runRepoWrite( + ["toggle", "integry/propr", "--no-auto-ci-followup"], + enabled + ); + assert.equal(disabled[0]?.autoFollowupOnFailedCi, false); + assert.equal(disabled[0]?.enabled, false); + assert.equal(disabled[1]?.autoFollowupOnFailedCi, true); +}); diff --git a/packages/cli/src/commands/repoCommands.ts b/packages/cli/src/commands/repoCommands.ts index 849bef569..a8b1efff0 100644 --- a/packages/cli/src/commands/repoCommands.ts +++ b/packages/cli/src/commands/repoCommands.ts @@ -154,12 +154,17 @@ function displayReposTable(repos: MonitoredRepo[]): void { "Status".length, ...repos.map((r) => formatEnabled(r.enabled).length) ); + const autoCiFollowupWidth = Math.max( + "Auto CI follow-up".length, + ...repos.map((r) => formatEnabled(r.autoFollowupOnFailedCi).length) + ); const header = [ "Repository".padEnd(nameWidth), "Alias".padEnd(aliasWidth), "Branch".padEnd(branchWidth), "Status".padEnd(statusWidth), + "Auto CI follow-up".padEnd(autoCiFollowupWidth), ].join(" "); console.log(header); @@ -171,6 +176,7 @@ function displayReposTable(repos: MonitoredRepo[]): void { (truncate(repo.alias, 20) || "-").padEnd(aliasWidth), (truncate(repo.baseBranch, 20) || "-").padEnd(branchWidth), formatEnabled(repo.enabled).padEnd(statusWidth), + formatEnabled(repo.autoFollowupOnFailedCi).padEnd(autoCiFollowupWidth), ].join(" "); console.log(row); @@ -242,6 +248,7 @@ Examples: .description("Add a repository to the monitored list for ProPR") .option("-a, --alias ", "Display alias for the repository") .option("-b, --branch ", "Base branch name (default: main/master)") + .option("--auto-ci-followup", "Enable automatic follow-up when CI fails (default: off)") .addHelpText("after", ` Argument: fullName Repository in owner/repo format @@ -249,11 +256,12 @@ Argument: Examples: $ propr repo add myorg/myrepo $ propr repo add myorg/myrepo -a "My Project" -b develop + $ propr repo add myorg/myrepo --auto-ci-followup `) .action( async ( fullName: string, - options: { alias?: string; branch?: string } + options: { alias?: string; branch?: string; autoCiFollowup?: boolean } ) => { try { if (!fullName.includes("/")) { @@ -279,6 +287,7 @@ Examples: alias: options.alias, baseBranch: options.branch, enabled: true, + autoFollowupOnFailedCi: options.autoCiFollowup ?? false, }); if (result.success) { @@ -290,6 +299,9 @@ Examples: if (options.branch) { console.log(` Base branch: ${options.branch}`); } + console.log( + ` Automatic CI follow-up: ${formatEnabled(options.autoCiFollowup ?? false)}` + ); console.log(""); console.log( `Total monitored repositories: ${result.repos_to_monitor.length}` @@ -397,24 +409,28 @@ Example: // repo toggle repo .command("toggle ") - .description("Enable or disable monitoring for a repository") + .description("Update monitoring or automatic CI follow-up for a repository") .option("--enable", "Enable monitoring for the repository") .option("--disable", "Disable monitoring for the repository") + .option("--auto-ci-followup", "Enable automatic follow-up when CI fails") + .option("--no-auto-ci-followup", "Disable automatic follow-up when CI fails") .addHelpText("after", ` Argument: fullName Repository in owner/repo format Note: - Exactly one of --enable or --disable must be specified. + Specify at least one monitoring or automatic CI follow-up option. Examples: $ propr repo toggle myorg/myrepo --enable $ propr repo toggle myorg/myrepo --disable + $ propr repo toggle myorg/myrepo --auto-ci-followup + $ propr repo toggle myorg/myrepo --no-auto-ci-followup `) .action( async ( fullName: string, - options: { enable?: boolean; disable?: boolean } + options: { enable?: boolean; disable?: boolean; autoCiFollowup?: boolean } ) => { try { if (options.enable && options.disable) { @@ -424,14 +440,16 @@ Examples: process.exit(1); } - if (!options.enable && !options.disable) { + if (!options.enable && !options.disable && options.autoCiFollowup === undefined) { console.error( - "Error: Must specify either --enable or --disable." + "Error: Must specify --enable, --disable, --auto-ci-followup, or --no-auto-ci-followup." ); console.log(""); console.log("Usage:"); console.log(` propr repo toggle ${fullName} --enable`); console.log(` propr repo toggle ${fullName} --disable`); + console.log(` propr repo toggle ${fullName} --auto-ci-followup`); + console.log(` propr repo toggle ${fullName} --no-auto-ci-followup`); process.exit(1); } @@ -444,19 +462,27 @@ Examples: process.exit(1); } - const enableState = options.enable ? true : false; - const actionWord = enableState ? "Enabling" : "Disabling"; + const enabled = options.enable ? true : options.disable ? false : undefined; + console.log(`Updating repository settings: ${fullName}...`); - console.log(`${actionWord} monitoring for repository: ${fullName}...`); - - const result = await updateRepo(fullName, { enabled: enableState }); + const result = await updateRepo(fullName, { + ...(enabled !== undefined && { enabled }), + ...(options.autoCiFollowup !== undefined && { + autoFollowupOnFailedCi: options.autoCiFollowup, + }), + }); if (result.success) { - const statusWord = enableState ? "enabled" : "disabled"; console.log(""); - console.log( - `Successfully ${statusWord} monitoring for repository: ${fullName}` - ); + console.log(`Successfully updated repository: ${fullName}`); + if (enabled !== undefined) { + console.log(` Monitoring: ${formatEnabled(enabled)}`); + } + if (options.autoCiFollowup !== undefined) { + console.log( + ` Automatic CI follow-up: ${formatEnabled(options.autoCiFollowup)}` + ); + } } else { console.error("Failed to update repository."); process.exit(1); diff --git a/packages/cli/src/commands/taskInspectCommands.test.ts b/packages/cli/src/commands/taskInspectCommands.test.ts index f5fb011ad..8a4481480 100644 --- a/packages/cli/src/commands/taskInspectCommands.test.ts +++ b/packages/cli/src/commands/taskInspectCommands.test.ts @@ -104,10 +104,9 @@ test("task inspect defaults to every canonical active state, including queued wo }, })); - assert.deepEqual( - result.requests.map((url) => url.searchParams.get("status")).sort(), - [...ACTIVE_TASK_LIFECYCLE_STATES].sort() - ); + const requestedStates = result.requests.map((url) => url.searchParams.get("status")); + assert.equal(requestedStates.length, ACTIVE_TASK_LIFECYCLE_STATES.length); + assert.deepEqual(new Set(requestedStates), new Set(ACTIVE_TASK_LIFECYCLE_STATES)); const output = JSON.parse(result.stdout.join("\n")); assert.deepEqual(output.states, [...ACTIVE_TASK_LIFECYCLE_STATES]); assert.equal(output.tasks[0].state, "claude_execution"); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index e2f12d722..d80c4a96d 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -78,6 +78,15 @@ export { TimeoutError, createApiError, } from "./api/index.js"; +export { + listSyntheticAgents, + saveSyntheticAgents, + deleteSyntheticAgent, +} from "./api/index.js"; +export type { + SyntheticAgentsResponse, + SaveSyntheticAgentsResponse, +} from "./api/index.js"; export type { HttpMethod, RequestOptions, @@ -231,7 +240,7 @@ Command Groups: Implementation: issue [implement] Tasks: task [inspect|list|get|stop|delete|followup|import|revert] Repositories: repo [list|add|remove|toggle|index|status] - Agents: agent [list|add|enable|disable|delete] + Agents: agent [list|add|enable|disable|delete|pool] Settings: setting [get|update|reindex-summaries] To-Dos: todo [list|get|add|complete|delete] Logs: log [list] diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index a78370f55..2ef9f7d65 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -11,7 +11,10 @@ import { type NormalizeApiBaseUrlOptions, type ProprApiBaseUrl, } from './baseUrl.js'; -import { DESKTOP_DISCOVERY_AUTHENTICATION_REQUIRED, ProprClientError } from './errors.js'; +import { + DESKTOP_DISCOVERY_AUTHENTICATION_REQUIRED, + ProprClientError, +} from './errors.js'; import { buildSocketConnection, connectProprSocket, @@ -88,6 +91,79 @@ const assertTimeout = (timeoutMs: number): void => { } }; +const createDesktopDiscoveryDeadline = (timeoutMs: number, callerSignal?: AbortSignal) => { + assertTimeout(timeoutMs); + const controller = new AbortController(); + let rejectDeadline!: (reason: unknown) => void; + let timedOut = false; + let deadlineSettled = false; + let deadlineReason: unknown; + const deadline = new Promise((_resolve, reject) => { rejectDeadline = reject; }); + // A caller may already be aborted before any operation is raced. + void deadline.catch(() => undefined); + const timeoutReason = new Error('desktop discovery timed out'); + const abortReason = new Error('desktop discovery was cancelled'); + const settleDeadline = (reason: unknown): boolean => { + if (deadlineSettled) return false; + deadlineSettled = true; + deadlineReason = reason; + rejectDeadline(reason); + return true; + }; + const timeout = setTimeout(() => { + if (!settleDeadline(timeoutReason)) return; + timedOut = true; + controller.abort(timeoutReason); + }, Math.max(1, timeoutMs)); + const onAbort = (): void => { + if (!settleDeadline(abortReason)) return; + controller.abort(callerSignal?.reason); + }; + if (callerSignal?.aborted) onAbort(); + else callerSignal?.addEventListener('abort', onAbort, { once: true }); + return { + signal: controller.signal, + race: (operation: Promise, disposeLateValue?: (value: T) => void): Promise => { + const observed = Promise.resolve(operation); + if (deadlineSettled) { + observed.then( + value => { try { disposeLateValue?.(value); } catch { /* best-effort ownership cleanup */ } }, + () => undefined, + ); + return Promise.reject(deadlineReason); + } + return new Promise((resolve, reject) => { + let settled = false; + deadline.catch(error => { + if (settled) return; + settled = true; + reject(error); + }); + observed.then( + value => { + if (settled || deadlineSettled) { + try { disposeLateValue?.(value); } catch { /* best-effort ownership cleanup */ } + return; + } + settled = true; + resolve(value); + }, + error => { + if (settled) return; + settled = true; + reject(error); + }, + ); + }); + }, + timedOut: (): boolean => timedOut, + dispose: (): void => { + clearTimeout(timeout); + callerSignal?.removeEventListener('abort', onAbort); + }, + }; +}; + export class ProprClient { readonly baseUrl: ProprApiBaseUrl; readonly authentication: ProprAuthentication; @@ -235,13 +311,39 @@ export class ProprClient { } async discoverDesktop(timeoutMs = 8000, signal?: AbortSignal): Promise { - const response = await this.fetch(this.url('/api/desktop/discovery'), { - cache: 'no-store', - credentials: 'omit', - headers: { Accept: 'application/json' }, - redirect: 'manual', - signal, - }, { timeoutMs }); + const deadline = createDesktopDiscoveryDeadline(timeoutMs, signal); + if (signal?.aborted) { + deadline.dispose(); + throw new ProprClientError('Desktop discovery was cancelled.', { + kind: 'aborted', cause: signal.reason, + }); + } + let response: Response; + try { + response = await deadline.race( + this.fetchImplementation(this.resolveRequestTarget(this.url('/api/desktop/discovery')), { + cache: 'no-store', + credentials: 'omit', + headers: { Accept: 'application/json' }, + redirect: 'manual', + signal: deadline.signal, + }), + lateResponse => { + try { void lateResponse.body?.cancel().catch(() => undefined); } catch { /* hostile late response */ } + }, + ); + } catch (cause) { + deadline.dispose(); + if (deadline.timedOut()) { + throw new ProprClientError('Desktop discovery timed out.', { kind: 'timeout', cause }); + } + if (signal?.aborted) { + throw new ProprClientError('Desktop discovery was cancelled.', { kind: 'aborted', cause }); + } + if (cause instanceof ProprClientError) throw cause; + throw new ProprClientError('The ProPR API could not be reached.', { kind: 'network', cause }); + } + try { const discoveryContentType = response.headers.get('content-type') ?.split(';', 1)[0]?.trim().toLowerCase(); if (!response.ok || response.redirected || discoveryContentType !== 'application/json') { @@ -266,20 +368,10 @@ export class ProprClient { const reader = response.body?.getReader(); const chunks: Uint8Array[] = []; let received = 0; - let rejectDeadline!: (reason: unknown) => void; - let bodyTimedOut = false; - const deadline = new Promise((_resolve, reject) => { rejectDeadline = reject; }); - const bodyTimer = setTimeout(() => { - bodyTimedOut = true; - rejectDeadline(new Error('desktop discovery body timed out')); - }, Math.max(1, timeoutMs)); - const onAbort = (): void => rejectDeadline(signal?.reason ?? new Error('desktop discovery was cancelled')); - if (signal?.aborted) onAbort(); - else signal?.addEventListener('abort', onAbort, { once: true }); try { if (reader) { while (true) { - const part = await Promise.race([reader.read(), deadline]); + const part = await deadline.race(reader.read()); if (part.done) break; received += part.value.byteLength; if (received > PROPR_CONNECT_DISCOVERY_MAX_BYTES) throw new Error('oversized'); @@ -288,16 +380,16 @@ export class ProprClient { } } catch (cause) { try { void reader?.cancel().catch(() => undefined); } catch { /* best-effort body cancellation */ } - if (bodyTimedOut) throw new ProprClientError('Desktop discovery timed out.', { kind: 'timeout', cause }); - if (signal?.aborted) throw new ProprClientError('Desktop discovery was cancelled.', { kind: 'aborted', cause }); + if (deadline.timedOut()) { + throw new ProprClientError('Desktop discovery timed out.', { kind: 'timeout', cause }); + } + if (signal?.aborted) { + throw new ProprClientError('Desktop discovery was cancelled.', { kind: 'aborted', cause }); + } throw new ProprClientError('The ProPR instance returned invalid desktop discovery metadata.', { kind: 'invalid_response', status: response.status, cause, }); - } finally { - clearTimeout(bodyTimer); - signal?.removeEventListener('abort', onAbort); - try { reader?.releaseLock(); } catch { /* hostile streams may retain a pending read */ } - } + } finally { try { reader?.releaseLock(); } catch { /* hostile streams may retain a pending read */ } } const contentEncoding = response.headers.get('content-encoding')?.trim().toLowerCase(); if (declaredLength !== null && (!contentEncoding || contentEncoding === 'identity') && Number(declaredLength) !== received) { @@ -325,6 +417,9 @@ export class ProprClient { metadata, ); return parseDesktopDiscovery(metadata, compatibility); + } finally { + deadline.dispose(); + } } async startDesktopPairing( diff --git a/packages/client/src/desktopPairing.ts b/packages/client/src/desktopPairing.ts index c6a2cb0d8..c261af7e3 100644 --- a/packages/client/src/desktopPairing.ts +++ b/packages/client/src/desktopPairing.ts @@ -1,17 +1,12 @@ import type { ProprApiCompatibilityResult, - ProprDesktopAuthenticationCapabilities, + ProprDesktopDiscovery as SharedProprDesktopDiscovery, } from '@propr/shared'; -import { canonicalProprHttpUrlOrigin } from '@propr/shared'; +import { canonicalProprHttpUrlOrigin, parseProprDesktopDiscovery } from '@propr/shared'; import type { ProprClient } from './client.js'; import { ProprClientError } from './errors.js'; -export interface ProprDesktopDiscovery { - product: string; - version: string; - apiCompatibility: string; - uiCompatibility: string; - desktopAuthentication: ProprDesktopAuthenticationCapabilities; +export interface ProprDesktopDiscovery extends SharedProprDesktopDiscovery { compatibility: ProprApiCompatibilityResult; } @@ -109,34 +104,17 @@ const validBinding = (value: unknown): value is ProprDesktopPairingBinding => { && /^[A-Za-z0-9_-]{22}$/.test(binding.credentialGeneration); }; -const validCapabilities = (value: unknown): value is ProprDesktopAuthenticationCapabilities => { - if (!value || typeof value !== 'object') return false; - const capabilities = value as Record; - return capabilities.protocolVersion === 2 - && typeof capabilities.browserPairing === 'boolean' - && typeof capabilities.instanceBearerTokens === 'boolean' - && typeof capabilities.socketIoBearerAuthentication === 'boolean'; -}; - export const parseDesktopDiscovery = ( value: unknown, compatibility: ProprApiCompatibilityResult, ): ProprDesktopDiscovery => { - const body = record(value); - if (body.product !== 'ProPR' || !string(body.version) || !string(body.apiCompatibility) - || !string(body.uiCompatibility) || !validCapabilities(body.desktopAuthentication)) { + const body = parseProprDesktopDiscovery(value); + if (!body) { throw new ProprClientError('The ProPR instance returned invalid desktop discovery metadata.', { kind: 'invalid_response', }); } - return { - product: body.product, - version: body.version, - apiCompatibility: body.apiCompatibility, - uiCompatibility: body.uiCompatibility, - desktopAuthentication: body.desktopAuthentication, - compatibility, - }; + return { ...body, compatibility }; }; export const parseDesktopPairingStart = ( diff --git a/packages/client/test/desktopPairing.test.ts b/packages/client/test/desktopPairing.test.ts index 451d2efa7..a7c5a8bd5 100644 --- a/packages/client/test/desktopPairing.test.ts +++ b/packages/client/test/desktopPairing.test.ts @@ -132,14 +132,20 @@ describe('desktop instance protocol', () => { && error.code === undefined); }); - it('uses the shared strict wire parser for malformed and oversized discovery', async () => { + it('uses the shared strict wire parser for missing, extra, malformed, duplicate, and oversized discovery', async () => { const valid = JSON.stringify(discovery); - for (const body of [ + const invalidBodies = [ JSON.stringify((({ publicInstanceIdentity: _omitted, ...rest }) => rest)(discovery)), - JSON.stringify({ ...discovery, unexpected: true }), + JSON.stringify({ ...discovery, account: 'must-not-be-present' }), + '{', valid.replace('"product":"ProPR"', '"product":"ProPR","product":"ProPR"'), `${valid}${' '.repeat(8 * 1024)}`, - ]) { + JSON.stringify({ ...discovery, publicInstanceIdentity: discovery.publicInstanceIdentity.toUpperCase() }), + JSON.stringify({ ...discovery, desktopAuthentication: { + ...discovery.desktopAuthentication, protocolVersion: 1, + } }), + ]; + for (const body of invalidBodies) { const client = new ProprClient({ baseUrl: 'https://propr.example.test', authentication: { type: 'none' }, @@ -150,6 +156,95 @@ describe('desktop instance protocol', () => { } }); + it('bounds discovery headers and body with one deadline and preserves caller cancellation', async () => { + let headerSignal: AbortSignal | null = null; + let resolveLateTimeout!: (response: Response) => void; + const stalledHeaders = new ProprClient({ + baseUrl: 'https://propr.example.test', + authentication: { type: 'none' }, + fetch: async (_input, init) => { + headerSignal = init?.signal ?? null; + return new Promise(resolve => { resolveLateTimeout = resolve; }); + }, + }); + await assert.rejects(bounded(stalledHeaders.discoverDesktop(20), 500), (error: unknown) => + error instanceof ProprClientError && error.kind === 'timeout'); + assert.equal(headerSignal?.aborted, true); + let timedOutBodyCancelled = 0; + resolveLateTimeout(new Response(new ReadableStream({ + start(controller) { controller.enqueue(new Uint8Array([1])); }, + cancel() { timedOutBodyCancelled += 1; }, + }))); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(timedOutBodyCancelled, 1); + + let bodyCancelled = 0; + const stalledBody = new ProprClient({ + baseUrl: 'https://propr.example.test', + authentication: { type: 'none' }, + fetch: async () => new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"schemaVersion":1')); + }, + cancel() { bodyCancelled += 1; }, + }), { headers: { 'Content-Type': 'application/json' } }), + }); + await assert.rejects(bounded(stalledBody.discoverDesktop(20), 500), (error: unknown) => + error instanceof ProprClientError && error.kind === 'timeout'); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(bodyCancelled, 1); + + const controller = new AbortController(); + let resolveLateCancellation!: (response: Response) => void; + const cancelled = new ProprClient({ + baseUrl: 'https://propr.example.test', + authentication: { type: 'none' }, + fetch: async () => new Promise(resolve => { resolveLateCancellation = resolve; }), + }).discoverDesktop(1_000, controller.signal); + controller.abort('caller cancelled'); + await assert.rejects(bounded(cancelled, 500), (error: unknown) => + error instanceof ProprClientError && error.kind === 'aborted'); + let abortedBodyCancelled = 0; + resolveLateCancellation(new Response(new ReadableStream({ + start(streamController) { streamController.enqueue(new Uint8Array([1])); }, + cancel() { abortedBodyCancelled += 1; }, + }))); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(abortedBodyCancelled, 1); + + const preAborted = new AbortController(); + preAborted.abort('already cancelled'); + let preAbortedRequests = 0; + await assert.rejects(new ProprClient({ + baseUrl: 'https://propr.example.test', + authentication: { type: 'none' }, + fetch: async () => { + preAbortedRequests += 1; + return json(discovery); + }, + }).discoverDesktop(1_000, preAborted.signal), (error: unknown) => + error instanceof ProprClientError && error.kind === 'aborted'); + assert.equal(preAbortedRequests, 0); + + const synchronouslyCancelled = new AbortController(); + let synchronousBodyCancelled = 0; + const synchronousCancellation = new ProprClient({ + baseUrl: 'https://propr.example.test', + authentication: { type: 'none' }, + fetch: async () => { + synchronouslyCancelled.abort('cancelled during fetch'); + return new Response(new ReadableStream({ + start(streamController) { streamController.enqueue(new Uint8Array([1])); }, + cancel() { synchronousBodyCancelled += 1; }, + })); + }, + }).discoverDesktop(1_000, synchronouslyCancelled.signal); + await assert.rejects(synchronousCancellation, (error: unknown) => + error instanceof ProprClientError && error.kind === 'aborted'); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(synchronousBodyCancelled, 1); + }); + it('discovers capabilities, opens approval, and polls to a single opaque token', async () => { const requests: Array<{ url: string; init?: RequestInit }> = []; let polls = 0; diff --git a/packages/core/src/agents/AgentRegistry.ts b/packages/core/src/agents/AgentRegistry.ts index 04ec1fe72..c5322f9c7 100644 --- a/packages/core/src/agents/AgentRegistry.ts +++ b/packages/core/src/agents/AgentRegistry.ts @@ -3,10 +3,6 @@ import os from 'os'; import logger from '../utils/logger.js'; import { Agent, AgentConfig } from './types.js'; import { ClaudeAgent } from './impl/ClaudeAgent.js'; -import { CodexAgent } from './impl/CodexAgent.js'; -import { AntigravityAgent } from './impl/AntigravityAgent.js'; -import { OpenCodeAgent } from './impl/OpenCodeAgent.js'; -import { VibeAgent } from './impl/VibeAgent.js'; import * as configManager from '../config/configManager.js'; import { ensureAgentBundleImage, ensureAgentDockerImage, executeDockerCommand } from '../claude/docker/dockerExecutor.js'; import { closeConnection } from '../db/connection.js'; @@ -16,6 +12,8 @@ import { AGENT_DEFAULT_VERSIONS } from './version/types.js'; import { DEFAULT_AGENT_DOCKER_IMAGES } from './constants.js'; import { loadAgentRuntimePackageState, resolveAgentRuntimeImage } from './runtime/agentRuntimePackages.js'; import { AGENT_DEFAULTS } from '../config/modelDefinitions.js'; +import { SyntheticAgentRegistry, type BeginSyntheticRoutingOptions, type SyntheticRoutingSession } from './SyntheticAgentRegistry.js'; +import { createAgentFromConfig } from './createAgentFromConfig.js'; export interface AgentRegistryOperationalStatus { unifiedAgentImage: { @@ -46,6 +44,7 @@ export class AgentRegistry { private pendingBackgroundRefresh: Promise | null = null; private unavailableUnifiedAgentImage: { imageTag?: string; error: string; recordedAt: string } | null = null; private unifiedAgentImageRetryTimer: NodeJS.Timeout | null = null; + private syntheticAgents = new SyntheticAgentRegistry(this.agents, this.agentsByAlias); private constructor() { // Private constructor for singleton pattern @@ -143,6 +142,8 @@ export class AgentRegistry { } } + await this.syntheticAgents.register(); + await this.captureRuntimePackageStateVersion(); this.initialized = true; logger.info({ @@ -176,6 +177,10 @@ export class AgentRegistry { return this.agentsByAlias.get(alias); } + beginRoutingSession(options: BeginSyntheticRoutingOptions): SyntheticRoutingSession { + return this.syntheticAgents.begin(options); + } + /** * Gets the default agent based on settings, then fallback to 'default' alias or first available. * Resolution order: @@ -428,20 +433,7 @@ export class AgentRegistry { * This is the factory method that handles different agent types. */ createAgentFromConfig(config: AgentConfig): Agent { - switch (config.type) { - case 'claude': - return new ClaudeAgent(config); - case 'codex': - return new CodexAgent(config); - case 'antigravity': - return new AntigravityAgent(config); - case 'opencode': - return new OpenCodeAgent(config); - case 'vibe': - return new VibeAgent(config); - default: - throw new Error(`Unknown agent type: ${config.type}`); - } + return createAgentFromConfig(config); } /** @@ -495,6 +487,8 @@ export class AgentRegistry { this.agents.set(defaultConfig.id, agent); this.agentsByAlias.set(defaultConfig.alias, agent); + await this.syntheticAgents.register(); + logger.info({ agentId: defaultConfig.id, agentAlias: defaultConfig.alias, @@ -512,6 +506,7 @@ export class AgentRegistry { // Clear agents and state this.agents.clear(); this.agentsByAlias.clear(); + this.syntheticAgents.clear(); this.initialized = false; // Close database connection diff --git a/packages/core/src/agents/SyntheticAgent.ts b/packages/core/src/agents/SyntheticAgent.ts new file mode 100644 index 000000000..63b7b8e07 --- /dev/null +++ b/packages/core/src/agents/SyntheticAgent.ts @@ -0,0 +1,71 @@ +import type { SyntheticAgentConfig } from '@propr/shared'; +import type { Agent, AgentConfig, AgentExecutionResult, AgentTaskOptions, AnalysisResult, AnalyzeOptions } from './types.js'; +import { + estimateTaskRequiredTokens, + type SyntheticRoutingService, + type SyntheticRoutingSession, +} from '../services/syntheticRoutingService.js'; +import { estimateTokens } from '../utils/tokenCalculation.js'; + +/** Agent facade that keeps the requested virtual identity while routing calls centrally. */ +export class SyntheticAgent implements Agent { + readonly config: AgentConfig; + readonly goalCapable = false; + + constructor( + readonly syntheticConfig: SyntheticAgentConfig, + private readonly routing: SyntheticRoutingService, + ) { + this.config = { + id: syntheticConfig.id, + // Existing consumers use this only for capability checks. The actual type + // and credentials always come from the selected physical member. + type: 'claude', + alias: syntheticConfig.alias, + enabled: syntheticConfig.enabled, + dockerImage: '', + configPath: '', + supportedModels: syntheticConfig.models.filter(model => model.enabled).map(model => model.id), + defaultModel: syntheticConfig.defaultModel, + }; + } + + analyze(prompt: string, options: AnalyzeOptions = {}): Promise { + const session = this.routing.begin({ + requestedAgentAlias: this.config.alias, + requestedModel: options.model || this.config.defaultModel, + promptTokens: estimateTokens(`${prompt}${options.context || ''}`), + }); + return session.analyze(prompt, options); + } + + executeTask(options: AgentTaskOptions): Promise { + const session = this.routing.begin({ + requestedAgentAlias: this.config.alias, + requestedModel: options.model || this.config.defaultModel, + requiredTokens: estimateTaskRequiredTokens(options), + }); + return session.executeTask(options); + } + + /** Begin a routed call for consumers that suppress the facade's own LLM log. */ + beginRoutingSession(requestedModel?: string): SyntheticRoutingSession { + return this.routing.begin({ + requestedAgentAlias: this.config.alias, + requestedModel: requestedModel || this.config.defaultModel, + }); + } + + async healthCheck(): Promise { + try { + const session = this.routing.begin({ + requestedAgentAlias: this.config.alias, + requestedModel: this.config.defaultModel, + requiredTokens: 0, + }); + return await this.routing.healthCheck(session); + } catch { + return false; + } + } +} diff --git a/packages/core/src/agents/SyntheticAgentRegistry.ts b/packages/core/src/agents/SyntheticAgentRegistry.ts new file mode 100644 index 000000000..b74a734a6 --- /dev/null +++ b/packages/core/src/agents/SyntheticAgentRegistry.ts @@ -0,0 +1,58 @@ +import logger from '../utils/logger.js'; +import { loadSyntheticAgents } from '../config/configManager.js'; +import { + SyntheticRoutingService, + type BeginSyntheticRoutingOptions, + type SyntheticRoutingSession, +} from '../services/syntheticRoutingService.js'; +import type { Agent } from './types.js'; +import { SyntheticAgent } from './SyntheticAgent.js'; + +export type { BeginSyntheticRoutingOptions, SyntheticRoutingSession } from '../services/syntheticRoutingService.js'; + +export class SyntheticAgentRegistry { + private routingService: SyntheticRoutingService | null = null; + + constructor( + private readonly agents: Map, + private readonly agentsByAlias: Map, + ) {} + + begin(options: BeginSyntheticRoutingOptions): SyntheticRoutingSession { + this.routingService ??= this.createRoutingService(); + return this.routingService.begin(options); + } + + async register(): Promise { + const configs = await loadSyntheticAgents(); + this.routingService = this.createRoutingService(); + for (const config of configs) { + if (!config.enabled) continue; + if (this.agentsByAlias.has(config.alias)) { + logger.error({ syntheticAgentAlias: config.alias }, 'Synthetic agent alias conflicts with a registered direct agent'); + continue; + } + if (this.agents.has(config.id)) { + logger.error({ syntheticAgentId: config.id, syntheticAgentAlias: config.alias }, 'Synthetic agent ID conflicts with a registered direct agent'); + continue; + } + const agent = new SyntheticAgent(config, this.routingService); + this.agents.set(config.id, agent); + this.agentsByAlias.set(config.alias, agent); + logger.info({ syntheticAgentAlias: config.alias, modelCount: config.models.length }, 'Synthetic agent registered'); + } + } + + clear(): void { + this.routingService = null; + } + + private createRoutingService(): SyntheticRoutingService { + return new SyntheticRoutingService({ + getDirectAgent: alias => { + const agent = this.agentsByAlias.get(alias); + return agent instanceof SyntheticAgent ? undefined : agent; + }, + }); + } +} diff --git a/packages/core/src/agents/createAgentFromConfig.ts b/packages/core/src/agents/createAgentFromConfig.ts new file mode 100644 index 000000000..deb29579f --- /dev/null +++ b/packages/core/src/agents/createAgentFromConfig.ts @@ -0,0 +1,23 @@ +import type { Agent, AgentConfig } from './types.js'; +import { AntigravityAgent } from './impl/AntigravityAgent.js'; +import { ClaudeAgent } from './impl/ClaudeAgent.js'; +import { CodexAgent } from './impl/CodexAgent.js'; +import { OpenCodeAgent } from './impl/OpenCodeAgent.js'; +import { VibeAgent } from './impl/VibeAgent.js'; + +export function createAgentFromConfig(config: AgentConfig): Agent { + switch (config.type) { + case 'claude': + return new ClaudeAgent(config); + case 'codex': + return new CodexAgent(config); + case 'antigravity': + return new AntigravityAgent(config); + case 'opencode': + return new OpenCodeAgent(config); + case 'vibe': + return new VibeAgent(config); + default: + throw new Error(`Unknown agent type: ${config.type}`); + } +} diff --git a/packages/core/src/agents/impl/AntigravityAgent.ts b/packages/core/src/agents/impl/AntigravityAgent.ts index 98c86aa7b..1c8534a3c 100644 --- a/packages/core/src/agents/impl/AntigravityAgent.ts +++ b/packages/core/src/agents/impl/AntigravityAgent.ts @@ -123,7 +123,7 @@ export class AntigravityAgent implements Agent { } async executeTask(options: AgentTaskOptions): Promise { - const { worktreePath, issueRef, prompt: customPrompt, model, isRetry = false, retryReason, onSessionId, onContainerId, githubToken, environment, taskId, prNumber } = options; + const { worktreePath, issueRef, prompt: customPrompt, model, isRetry = false, retryReason, onSessionId, onContainerId, githubToken, environment, taskId, prNumber, metadata } = options; const startTime = Date.now(); const effectiveModel = model || this.config.defaultModel; const transcriptPath = this.createTransientTranscriptPath(taskId); @@ -148,7 +148,7 @@ export class AntigravityAgent implements Agent { ); const executionTime = Date.now() - startTime; - return this.processExecutionResult({ result, executionTime, issueRef, effectiveModel, prompt, worktreePath, worktreeGitContent, onSessionId, taskId, prNumber, isRetry, retryReason, usageMetrics, transcriptPath }); + return this.processExecutionResult({ result, executionTime, issueRef, effectiveModel, prompt, worktreePath, worktreeGitContent, onSessionId, taskId, prNumber, isRetry, retryReason, usageMetrics, transcriptPath, metadata }); } catch (error) { return this.handleExecutionError(error, Date.now() - startTime, issueRef, effectiveModel); } finally { @@ -168,9 +168,9 @@ export class AntigravityAgent implements Agent { issueRef: { number: number; repoOwner: string; repoName: string }; effectiveModel: string | undefined; prompt: string; worktreePath: string; worktreeGitContent: string | null; onSessionId?: (sessionId: string, conversationId?: string) => void; taskId?: string; prNumber?: number; isRetry?: boolean; retryReason?: string; usageMetrics?: UsageTrackingMetrics | null; - transcriptPath?: string; + transcriptPath?: string; metadata?: Record; }): Promise { - const { result, executionTime, issueRef, effectiveModel, prompt, worktreePath, worktreeGitContent, onSessionId, taskId, prNumber, isRetry, retryReason, usageMetrics, transcriptPath } = opts; + const { result, executionTime, issueRef, effectiveModel, prompt, worktreePath, worktreeGitContent, onSessionId, taskId, prNumber, isRetry, retryReason, usageMetrics, transcriptPath, metadata } = opts; logger.info({ issueNumber: issueRef.number, repository: `${issueRef.repoOwner}/${issueRef.repoName}`, executionTime, outputLength: result.stdout?.length || 0, success: result.exitCode === 0, exitCode: result.exitCode, agentAlias: this.config.alias }, 'Antigravity agent execution completed'); const parsed = this.resolveSessionOutput(result.stdout, transcriptPath, onSessionId); @@ -191,7 +191,7 @@ export class AntigravityAgent implements Agent { terminationReason }; - await this.persistImplementationLog({ executionTime, issueRef, resolvedModel, finalTokenUsage, agentResult, taskId, prNumber, isRetry, retryReason, usageMetrics }); + await this.persistImplementationLog({ executionTime, issueRef, resolvedModel, finalTokenUsage, agentResult, taskId, prNumber, isRetry, retryReason, usageMetrics, metadata }); if (!agentResult.success) logger.error({ issueNumber: issueRef.number, exitCode: result.exitCode, stderr: result.stderr, agentAlias: this.config.alias }, 'Antigravity agent execution failed'); else { logger.info({ issueNumber: issueRef.number, model: resolvedModel, agentAlias: this.config.alias }, 'Antigravity agent execution succeeded'); verifyWorktreePostExecution(worktreePath, issueRef.number, worktreeGitContent); } @@ -328,16 +328,16 @@ export class AntigravityAgent implements Agent { executionTime: number; issueRef: { number: number; repoOwner: string; repoName: string }; resolvedModel: string; finalTokenUsage?: TokenUsage; agentResult: AgentExecutionResult; taskId?: string; prNumber?: number; - isRetry?: boolean; retryReason?: string; usageMetrics?: UsageTrackingMetrics | null; + isRetry?: boolean; retryReason?: string; usageMetrics?: UsageTrackingMetrics | null; metadata?: Record; }): Promise { - const { executionTime, issueRef, resolvedModel, finalTokenUsage, agentResult, taskId, prNumber, isRetry, retryReason, usageMetrics } = opts; + const { executionTime, issueRef, resolvedModel, finalTokenUsage, agentResult, taskId, prNumber, isRetry, retryReason, usageMetrics, metadata } = opts; const repository = `${issueRef.repoOwner}/${issueRef.repoName}`; const logEntry = createLlmLogFromAnalysis({ executionType: 'implementation', modelUsed: resolvedModel, executionTimeMs: executionTime, success: agentResult.success, tokenUsage: finalTokenUsage, error: agentResult.success ? undefined : (agentResult.logs || 'Execution failed'), sessionId: agentResult.sessionId, draftId: taskId, repository, agentAlias: this.config.alias, - metadata: { isRetry, retryReason }, + metadata: { ...metadata, isRetry, retryReason }, usageMetrics: usageMetrics ? { preCall: usageMetrics.preCall, postCall: usageMetrics.postCall, delta: usageMetrics.delta, timestamp: usageMetrics.timestamp, agent: usageMetrics.agent } : undefined, usageMetricRecords: usageMetrics?.records, workRef: buildTaskWorkRef(taskId, issueRef.number, repository, prNumber), diff --git a/packages/core/src/agents/impl/ClaudeAgent.ts b/packages/core/src/agents/impl/ClaudeAgent.ts index 5013516a7..070aedfa9 100644 --- a/packages/core/src/agents/impl/ClaudeAgent.ts +++ b/packages/core/src/agents/impl/ClaudeAgent.ts @@ -92,7 +92,7 @@ export class ClaudeAgent implements Agent { const { worktreePath, issueRef, prompt: customPrompt, model, systemPrompt, isRetry = false, retryReason, branchName, issueDetails, - onSessionId, onContainerId, githubToken, tools, environment, taskId, prNumber, reasoningLevel + onSessionId, onContainerId, githubToken, tools, environment, taskId, prNumber, reasoningLevel, metadata } = options; const startTime = Date.now(); @@ -143,7 +143,7 @@ export class ClaudeAgent implements Agent { await this.persistExecutionLogs({ result, prompt, issueRef, modelUsed, isRetry, retryReason, executionTime, correctedTokenUsage, taskId, prNumber, - reasoningLevel: effectiveReasoningLevel || undefined, usageMetrics + reasoningLevel: effectiveReasoningLevel || undefined, usageMetrics, metadata }); if (!response.success) { @@ -308,7 +308,7 @@ export class ClaudeAgent implements Agent { private async persistExecutionLogs(params: PersistLogsParams): Promise { const { result, prompt, issueRef, modelUsed, isRetry, retryReason, executionTime, - correctedTokenUsage, taskId, prNumber, reasoningLevel, usageMetrics + correctedTokenUsage, taskId, prNumber, reasoningLevel, usageMetrics, metadata } = params; const claudeOutput = parseStreamJsonOutput(result); @@ -323,7 +323,7 @@ export class ClaudeAgent implements Agent { sessionId: claudeOutput.sessionId ?? undefined, draftId: taskId, repository, agentAlias: this.config.alias, reasoningLevel, - metadata: { isRetry, retryReason, conversationId: claudeOutput.conversationId }, + metadata: { ...metadata, isRetry, retryReason, conversationId: claudeOutput.conversationId }, usageMetrics: usageMetrics ? { preCall: usageMetrics.preCall, postCall: usageMetrics.postCall, delta: usageMetrics.delta, timestamp: usageMetrics.timestamp, agent: usageMetrics.agent diff --git a/packages/core/src/agents/impl/CodexAgent.ts b/packages/core/src/agents/impl/CodexAgent.ts index ad8dcbc76..95d84e289 100644 --- a/packages/core/src/agents/impl/CodexAgent.ts +++ b/packages/core/src/agents/impl/CodexAgent.ts @@ -42,7 +42,7 @@ export class CodexAgent implements Agent { async executeTask(options: AgentTaskOptions): Promise { const { worktreePath, issueRef, prompt: customPrompt, model, systemPrompt, isRetry = false, retryReason, branchName, issueDetails, - onSessionId, onContainerId, githubToken, environment, taskId, prNumber, reasoningLevel } = options; + onSessionId, onContainerId, githubToken, environment, taskId, prNumber, reasoningLevel, metadata } = options; const startTime = Date.now(); const effectiveModel = model || this.config.defaultModel; @@ -88,7 +88,7 @@ export class CodexAgent implements Agent { await this.persistTaskLog({ response, parsedOutput, executionTime, modelUsed: response.modelUsed, prompt, usageMetrics, - issueRef, repo, taskId, prNumber, isRetry, retryReason + issueRef, repo, taskId, prNumber, isRetry, retryReason, metadata }); this.handleTaskCompletion({ response, issueNumber: issueRef.number, result, parsedOutput, worktreePath, worktreeGitContent }); @@ -142,9 +142,9 @@ export class CodexAgent implements Agent { executionTime: number; modelUsed: string; prompt: string; usageMetrics: CodexUsageMetrics; issueRef: AgentTaskOptions['issueRef']; repo: string; - taskId?: string; prNumber?: number; isRetry: boolean; retryReason?: string; + taskId?: string; prNumber?: number; isRetry: boolean; retryReason?: string; metadata?: Record; }): Promise { - const { response, parsedOutput, executionTime, modelUsed, usageMetrics, issueRef, repo, taskId, prNumber, isRetry, retryReason } = params; + const { response, parsedOutput, executionTime, modelUsed, usageMetrics, issueRef, repo, taskId, prNumber, isRetry, retryReason, metadata } = params; await storeCodexPromptInRedis({ codexOutput: parsedOutput, prompt: params.prompt, issueRef, model: modelUsed, isRetry, retryReason }); const logEntry = createLlmLogFromAnalysis({ executionType: 'implementation', modelUsed, @@ -154,7 +154,7 @@ export class CodexAgent implements Agent { sessionId: parsedOutput.sessionId, draftId: taskId, repository: `${issueRef.repoOwner}/${issueRef.repoName}`, agentAlias: this.config.alias, reasoningLevel: response.reasoningLevel, - metadata: { isRetry, retryReason, conversationId: parsedOutput.conversationId }, + metadata: { ...metadata, isRetry, retryReason, conversationId: parsedOutput.conversationId }, ...this.formatUsageMetrics(usageMetrics), workRef: buildTaskWorkRef(taskId, issueRef.number, repo, prNumber), }); diff --git a/packages/core/src/agents/impl/OpenCodeAgent.ts b/packages/core/src/agents/impl/OpenCodeAgent.ts index 766a298c1..bf1e49cd0 100644 --- a/packages/core/src/agents/impl/OpenCodeAgent.ts +++ b/packages/core/src/agents/impl/OpenCodeAgent.ts @@ -51,7 +51,7 @@ export class OpenCodeAgent implements Agent { } async executeTask(options: AgentTaskOptions): Promise { - const { worktreePath, issueRef, prompt: customPrompt, model, systemPrompt, isRetry = false, retryReason, branchName, issueDetails, onSessionId, onContainerId, githubToken, taskId, prNumber } = options; + const { worktreePath, issueRef, prompt: customPrompt, model, systemPrompt, isRetry = false, retryReason, branchName, issueDetails, onSessionId, onContainerId, githubToken, taskId, prNumber, metadata } = options; const startTime = Date.now(); const effectiveModel = model || this.config.defaultModel; const repo = `${issueRef.repoOwner}/${issueRef.repoName}`; @@ -114,7 +114,7 @@ export class OpenCodeAgent implements Agent { usageMetrics: usageMetrics ?? undefined }; - await this.persistExecutionLogSafely({ response, executionTime, modelUsed, prompt, issueRef, taskId, prNumber, isRetry, retryReason, usageMetrics }); + await this.persistExecutionLogSafely({ response, executionTime, modelUsed, prompt, issueRef, taskId, prNumber, isRetry, retryReason, usageMetrics, metadata }); if (!response.success) { logger.error({ issueNumber: issueRef.number, exitCode: result.exitCode, stderr: result.stderr, agentAlias: this.config.alias, error: parsedOutput.error }, 'OpenCode agent execution failed'); @@ -207,8 +207,9 @@ export class OpenCodeAgent implements Agent { isRetry: boolean; retryReason?: string; usageMetrics?: UsageTrackingMetrics | null; + metadata?: Record; }): Promise { - const { response, executionTime, modelUsed, issueRef, taskId, prNumber, isRetry, retryReason, usageMetrics } = opts; + const { response, executionTime, modelUsed, issueRef, taskId, prNumber, isRetry, retryReason, usageMetrics, metadata } = opts; const repository = `${issueRef.repoOwner}/${issueRef.repoName}`; await persistLlmLog(createLlmLogFromAgentExecution({ executionType: 'implementation', @@ -221,7 +222,7 @@ export class OpenCodeAgent implements Agent { draftId: taskId, repository, agentAlias: this.config.alias, - metadata: { isRetry, retryReason }, + metadata: { ...metadata, isRetry, retryReason }, ...formatUsageMetrics(usageMetrics), workRef: buildTaskWorkRef(taskId, issueRef.number, repository, prNumber), })); diff --git a/packages/core/src/agents/impl/VibeAgent.ts b/packages/core/src/agents/impl/VibeAgent.ts index 9723b517f..7c6f8301b 100644 --- a/packages/core/src/agents/impl/VibeAgent.ts +++ b/packages/core/src/agents/impl/VibeAgent.ts @@ -58,7 +58,7 @@ export class VibeAgent implements Agent { } async executeTask(options: AgentTaskOptions): Promise { - const { worktreePath, issueRef, prompt: customPrompt, model, isRetry = false, retryReason, onSessionId, onContainerId, githubToken, taskId, prNumber } = options; + const { worktreePath, issueRef, prompt: customPrompt, model, isRetry = false, retryReason, onSessionId, onContainerId, githubToken, taskId, prNumber, metadata } = options; const startTime = Date.now(); const effectiveModel = model || this.config.defaultModel; const repository = `${issueRef.repoOwner}/${issueRef.repoName}`; @@ -152,7 +152,7 @@ export class VibeAgent implements Agent { draftId: taskId, repository, agentAlias: this.config.alias, - metadata: buildLogMetadata({ isRetry, retryReason }, result, !success), + metadata: { ...metadata, ...buildLogMetadata({ isRetry, retryReason }, result, !success) }, usageMetrics: usage.metrics, usageMetricRecords: usage.records, workRef: buildTaskWorkRef(taskId, issueRef.number, repository, prNumber), diff --git a/packages/core/src/agents/impl/utils/claudeOutputHelpers.ts b/packages/core/src/agents/impl/utils/claudeOutputHelpers.ts index a705efbfb..76361ce5a 100644 --- a/packages/core/src/agents/impl/utils/claudeOutputHelpers.ts +++ b/packages/core/src/agents/impl/utils/claudeOutputHelpers.ts @@ -51,4 +51,5 @@ export interface PersistLogsParams { prNumber?: number; reasoningLevel?: string; usageMetrics?: UsageTrackingMetrics | null; + metadata?: Record; } diff --git a/packages/core/src/agents/impl/utils/codexDockerArgsBuilder.ts b/packages/core/src/agents/impl/utils/codexDockerArgsBuilder.ts index cb3c24c6a..1096b5331 100644 --- a/packages/core/src/agents/impl/utils/codexDockerArgsBuilder.ts +++ b/packages/core/src/agents/impl/utils/codexDockerArgsBuilder.ts @@ -11,6 +11,67 @@ import { const CONTAINER_CONFIG_PATH = '/home/node/.codex'; const GITHUB_CREDENTIAL_ENV_NAMES = new Set(['GH_TOKEN', 'GITHUB_TOKEN', 'GITHUB_ACCESS_TOKEN']); const GITHUB_CREDENTIAL_ENV_PATTERN = /^(?:GH|GITHUB)_.*(?:TOKEN|KEY|SECRET|PASSWORD|PAT|PRIVATE_KEY)$/; +const PROPR_OPENAI_PROVIDER_ID = 'propr_openai'; + +export const DEFAULT_CODEX_STREAM_TRANSPORT = 'websocket' as const; +export const DEFAULT_CODEX_STREAM_IDLE_TIMEOUT_MS = 30 * 60 * 1000; +export const DEFAULT_CODEX_STREAM_MAX_RETRIES = 5; + +export type CodexStreamTransport = 'sse' | 'websocket' | 'inherit'; + +export interface CodexStreamConfig { + transport: CodexStreamTransport; + idleTimeoutMs: number; + maxRetries: number; +} + +function parseIntegerSetting(value: string | undefined, fallback: number, allowZero: boolean): number { + if (!value?.trim()) return fallback; + const parsed = Number(value); + return Number.isSafeInteger(parsed) && (allowZero ? parsed >= 0 : parsed > 0) + ? parsed + : fallback; +} + +export function resolveCodexStreamConfig( + environment: Record = process.env +): CodexStreamConfig { + const configuredTransport = environment.CODEX_STREAM_TRANSPORT?.trim().toLowerCase(); + const transport: CodexStreamTransport = configuredTransport === 'sse' + || configuredTransport === 'websocket' + || configuredTransport === 'inherit' + ? configuredTransport + : DEFAULT_CODEX_STREAM_TRANSPORT; + + return { + transport, + idleTimeoutMs: parseIntegerSetting( + environment.CODEX_STREAM_IDLE_TIMEOUT_MS, + DEFAULT_CODEX_STREAM_IDLE_TIMEOUT_MS, + false + ), + maxRetries: parseIntegerSetting( + environment.CODEX_STREAM_MAX_RETRIES, + DEFAULT_CODEX_STREAM_MAX_RETRIES, + true + ), + }; +} + +function buildCodexStreamConfigArgs(config: CodexStreamConfig): string[] { + if (config.transport === 'inherit') return []; + + return [ + '--config', `model_provider="${PROPR_OPENAI_PROVIDER_ID}"`, + '--config', `model_providers.${PROPR_OPENAI_PROVIDER_ID}.name="OpenAI"`, + '--config', `model_providers.${PROPR_OPENAI_PROVIDER_ID}.wire_api="responses"`, + '--config', `model_providers.${PROPR_OPENAI_PROVIDER_ID}.requires_openai_auth=true`, + '--config', `model_providers.${PROPR_OPENAI_PROVIDER_ID}.supports_websockets=${config.transport === 'websocket'}`, + '--config', `model_providers.${PROPR_OPENAI_PROVIDER_ID}.supports_standalone_web_search=true`, + '--config', `model_providers.${PROPR_OPENAI_PROVIDER_ID}.stream_idle_timeout_ms=${config.idleTimeoutMs}`, + '--config', `model_providers.${PROPR_OPENAI_PROVIDER_ID}.stream_max_retries=${config.maxRetries}`, + ]; +} function isGitHubCredentialEnvironmentVariable(name: string): boolean { const normalizedName = name.toUpperCase(); @@ -59,6 +120,11 @@ export function buildCodexDockerArgs(config: AgentConfig, params: CodexDockerArg const dockerImage = config.dockerImage; const configPath = resolveConfigPath(config.configPath); const envVars = buildEnvironmentVariableArgs([config.envVars, environment], repositoryInspection); + const streamConfig = resolveCodexStreamConfig({ + ...process.env, + ...config.envVars, + ...environment, + }); const shortTaskId = createContainerExecutionId(taskId); const taskType = executionType || (issueNumber === 0 ? 'analysis' : `issue-${issueNumber}`); const containerName = `${config.alias || 'codex'}-${taskType}-${shortTaskId}`; @@ -85,6 +151,7 @@ export function buildCodexDockerArgs(config: AgentConfig, params: CodexDockerArg ...(repositoryInspection ? buildCodexRepositoryScoutArgs() : ['--dangerously-bypass-approvals-and-sandbox', '--config', 'features.multi_agent=false']), + ...buildCodexStreamConfigArgs(streamConfig), ...(reasoningLevel ? ['--config', `model_reasoning_effort="${reasoningLevel}"`] : []), '--skip-git-repo-check', '--cd', '/home/node/workspace', diff --git a/packages/core/src/agents/syntheticRouting.ts b/packages/core/src/agents/syntheticRouting.ts new file mode 100644 index 000000000..36f5757a1 --- /dev/null +++ b/packages/core/src/agents/syntheticRouting.ts @@ -0,0 +1,2 @@ +export { SyntheticAgent } from './SyntheticAgent.js'; +export * from '../services/syntheticRoutingService.js'; diff --git a/packages/core/src/agents/types.ts b/packages/core/src/agents/types.ts index 5579bc5e7..604c593fd 100644 --- a/packages/core/src/agents/types.ts +++ b/packages/core/src/agents/types.ts @@ -68,6 +68,9 @@ export interface AgentTaskOptions { /** Per-execution environment variables to inject into the agent container. */ environment?: Record; + /** Additional structured fields persisted with the execution LLM log. */ + metadata?: Record; + // Task ID for abort signal checking taskId?: string; diff --git a/packages/core/src/agents/version/types.ts b/packages/core/src/agents/version/types.ts index 3f5afe994..421de65cc 100644 --- a/packages/core/src/agents/version/types.ts +++ b/packages/core/src/agents/version/types.ts @@ -38,7 +38,7 @@ export const AGENT_CLI_TAGS: Record = { */ export const AGENT_DEFAULT_VERSIONS: Record = { claude: '2.1.220', - codex: '0.146.0', + codex: '0.151.0', antigravity: '1.1.13', opencode: '1.18.9', vibe: '2.23.1' diff --git a/packages/core/src/claude/claudeService.ts b/packages/core/src/claude/claudeService.ts index 5dfe17344..f83cd2cb9 100644 --- a/packages/core/src/claude/claudeService.ts +++ b/packages/core/src/claude/claudeService.ts @@ -29,6 +29,7 @@ import type { ReasoningLevel } from '@propr/shared'; import { loadSummarizationSettings } from '../config/configManager.js'; import { resolveConfiguredModel } from '../config/configuredModel.js'; import { resolveAgentTerminationReason } from '../agents/termination.js'; +import type { SyntheticRoutingSession } from '../services/syntheticRoutingService.js'; export { UsageLimitError }; export type { IssueRef, IssueDetails }; @@ -103,6 +104,8 @@ export interface RunLightweightLLMAnalysisOptions { reasoningLevel?: ReasoningLevel; /** Whether an omitted reasoning level may inherit the configured per-model/global levels. Defaults to false. */ useConfiguredReasoningLevel?: boolean; + /** Preselected call-scoped route used by context-sensitive callers. */ + routingSession?: SyntheticRoutingSession; } /** @deprecated Use AgentRegistry.getDefaultAgent().executeTask() instead. */ @@ -298,10 +301,11 @@ interface AgentExecutionParams { reasoningLevel?: ReasoningLevel; useConfiguredReasoningLevel?: boolean; correlatedLogger: ReturnType; + routingSession?: SyntheticRoutingSession; } async function tryExecuteWithAgent(params: AgentExecutionParams): Promise { - const { agentAlias, modelOverride, prompt, taskId, taskNumber, prNumber, executionType, correlationId, repository, metadata, timeoutMs, reasoningLevel, useConfiguredReasoningLevel, correlatedLogger } = params; + const { agentAlias, modelOverride, prompt, taskId, taskNumber, prNumber, executionType, correlationId, repository, metadata, timeoutMs, reasoningLevel, useConfiguredReasoningLevel, correlatedLogger, routingSession } = params; const registry = AgentRegistry.getInstance(); await registry.ensureInitialized(); @@ -313,7 +317,10 @@ async function tryExecuteWithAgent(params: AgentExecutionParams): Promise { - const { prompt, model, correlationId, taskId, prNumber, issueRef, executionType = 'other', metadata, timeoutMs, reasoningLevel, useConfiguredReasoningLevel } = options; + const { prompt, model, correlationId, taskId, prNumber, issueRef, executionType = 'other', metadata, timeoutMs, reasoningLevel, useConfiguredReasoningLevel, routingSession } = options; const correlatedLogger = logger.withCorrelation(correlationId); const { agentAlias, modelOverride, effectiveModel } = parseAgentModelFormat(model, correlatedLogger); @@ -393,7 +400,7 @@ export async function runLightweightLLMAnalysis(options: RunLightweightLLMAnalys // Pass all logging fields to agent - agent handles persistence internally const analysisResult = await tryExecuteWithAgent({ agentAlias, modelOverride, prompt, taskId, taskNumber, prNumber, executionType, - correlationId, repository, metadata, timeoutMs, reasoningLevel, useConfiguredReasoningLevel, correlatedLogger + correlationId, repository, metadata, timeoutMs, reasoningLevel, useConfiguredReasoningLevel, correlatedLogger, routingSession }); if (analysisResult !== null) { if (!analysisResult.success) { diff --git a/packages/core/src/codex/codexHelpers.ts b/packages/core/src/codex/codexHelpers.ts index 221eba45c..f07ea0af3 100644 --- a/packages/core/src/codex/codexHelpers.ts +++ b/packages/core/src/codex/codexHelpers.ts @@ -208,8 +208,15 @@ function addCodexTokenUsage(usage: CodexEvent['usage'] | undefined, state: Parse } function handleErrorEvent(event: CodexEvent, state: ParseState): void { - state.isError = true; - state.errorMessage = event.message; + // Codex emits retry progress as `error` events even though the turn is + // still active. A later successful completion must not remain poisoned by + // one of these transient transport notifications. If every reconnect is + // exhausted, Codex emits a separate terminal error (and exits non-zero). + const isReconnectNotice = event.message?.startsWith('Reconnecting... '); + if (!isReconnectNotice) { + state.isError = true; + state.errorMessage = event.message; + } state.logs += `[Error] ${event.message}\n`; } diff --git a/packages/core/src/config/configManager.ts b/packages/core/src/config/configManager.ts index b49080e19..d246724fb 100644 --- a/packages/core/src/config/configManager.ts +++ b/packages/core/src/config/configManager.ts @@ -17,6 +17,7 @@ export interface RepoToMonitor { id: string; // UUID, required for uniqueness name: string; // owner/repo enabled: boolean; + autoFollowupOnFailedCi?: boolean; // Defaults to false for legacy configurations alias?: string; // Optional display name baseBranch?: string; // Optional specific branch to monitor defaultBranch?: string; // Optional repository default branch for demo metadata @@ -255,6 +256,12 @@ export { saveAgentTankSettings } from './configManagerAgents.js'; +export { + SYNTHETIC_AGENTS_CONFIG_KEY, + loadSyntheticAgents, + saveSyntheticAgents +} from './configManagerSyntheticAgents.js'; + // --- Auto Resolve Merge Conflicts --- /** diff --git a/packages/core/src/config/configManagerSyntheticAgents.ts b/packages/core/src/config/configManagerSyntheticAgents.ts new file mode 100644 index 000000000..cec167c77 --- /dev/null +++ b/packages/core/src/config/configManagerSyntheticAgents.ts @@ -0,0 +1,35 @@ +import type { Knex } from 'knex'; +import { + parseSyntheticAgentConfigs, + type SyntheticAgentConfig, +} from '@propr/shared'; +import { getConfig, getConfigWithClient, saveConfig } from './configStore.js'; + +export const SYNTHETIC_AGENTS_CONFIG_KEY = 'synthetic_agents'; +const DEFAULT_SYNTHETIC_AGENTS: SyntheticAgentConfig[] = []; + +export async function loadSyntheticAgents( + client?: Knex | Knex.Transaction, +): Promise { + const value = client + ? await getConfigWithClient( + SYNTHETIC_AGENTS_CONFIG_KEY, + DEFAULT_SYNTHETIC_AGENTS, + client, + ) + : await getConfig( + SYNTHETIC_AGENTS_CONFIG_KEY, + DEFAULT_SYNTHETIC_AGENTS, + ); + + return parseSyntheticAgentConfigs(value); +} + +export async function saveSyntheticAgents( + value: unknown, + client?: Knex | Knex.Transaction, +): Promise { + const normalized = parseSyntheticAgentConfigs(value); + await saveConfig(SYNTHETIC_AGENTS_CONFIG_KEY, normalized, client); + return normalized; +} diff --git a/packages/core/src/daemon/configLoader.ts b/packages/core/src/daemon/configLoader.ts index 1686a3289..f85c61cd7 100644 --- a/packages/core/src/daemon/configLoader.ts +++ b/packages/core/src/daemon/configLoader.ts @@ -1,6 +1,6 @@ import logger from '../utils/logger.js'; import { getAuthenticatedOctokit } from '../auth/githubAuth.js'; -import { loadMonitoredRepos, loadSettings, loadAiPrimaryTag, loadPrimaryProcessingLabels } from '../config/configManager.js'; +import { loadMonitoredRepos, loadMonitoredReposRaw, loadSettings, loadAiPrimaryTag, loadPrimaryProcessingLabels } from '../config/configManager.js'; import { invalidateSettingsCache } from '../services/relevance/keywordExtractor.js'; interface Settings { @@ -32,6 +32,36 @@ export function isMonitoredRepository(repository: string, repos: readonly string && repos.some(configured => configured.trim().toLowerCase() === normalizedRepository); } +/** + * Returns whether automatic failed-CI follow-up is enabled for a repository. + * Missing or malformed options are treated as disabled so legacy repository + * configurations cannot opt into autonomous follow-up work after an upgrade. + */ +export async function isAutoCiFollowupEnabledForRepository( + owner: string, + repo: string, + loadConfiguredRepos: typeof loadMonitoredReposRaw = loadMonitoredReposRaw, +): Promise { + const repository = `${owner.trim()}/${repo.trim()}`.toLowerCase(); + if (repository === '/') return false; + + try { + const configuredRepos = await loadConfiguredRepos(); + // Branch-specific entries can share a repository name. Treat the option + // as enabled when any matching entry explicitly opts in so the result is + // independent of configuration order while the UI keeps those entries + // synchronized on subsequent writes. + return configuredRepos.some(candidate => + candidate.name.trim().toLowerCase() === repository + && candidate.autoFollowupOnFailedCi === true + ); + } catch (error) { + const err = error as Error; + logger.warn({ repository, error: err.message }, 'Failed to load automatic CI follow-up repository configuration; treating it as disabled'); + return false; + } +} + export async function resolveMonitoredRepositories( environment: NodeJS.ProcessEnv = process.env, loadPersisted: () => Promise = loadMonitoredRepos, diff --git a/packages/core/src/db/migrationGate.ts b/packages/core/src/db/migrationGate.ts index db2830669..b90f1bd52 100644 --- a/packages/core/src/db/migrationGate.ts +++ b/packages/core/src/db/migrationGate.ts @@ -5,6 +5,43 @@ export interface MigrationDatabase { }; } +export interface MigrationGateOptions { + lockRetryAttempts?: number; + lockRetryDelayMs?: number; + wait?: (milliseconds: number) => Promise; +} + +const DEFAULT_MIGRATION_LOCK_RETRY_ATTEMPTS = 60; +const DEFAULT_MIGRATION_LOCK_RETRY_DELAY_MS = 1_000; + +function isMigrationLockError(error: unknown): error is Error { + return error instanceof Error + && (error.name === 'MigrationLocked' + || error.message === 'Migration table is already locked'); +} + +async function migrateWithLockRetry( + database: MigrationDatabase, + options: MigrationGateOptions, +): Promise { + const retryAttempts = options.lockRetryAttempts + ?? DEFAULT_MIGRATION_LOCK_RETRY_ATTEMPTS; + const retryDelayMs = options.lockRetryDelayMs + ?? DEFAULT_MIGRATION_LOCK_RETRY_DELAY_MS; + const wait = options.wait + ?? (milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))); + + for (let attempt = 0; ; attempt += 1) { + try { + await database.migrate.latest(); + return; + } catch (error) { + if (!isMigrationLockError(error) || attempt >= retryAttempts) throw error; + await wait(retryDelayMs); + } + } +} + /** * Apply every pending migration before a process is allowed to start. * @@ -13,13 +50,16 @@ export interface MigrationDatabase { * operation failing rejects startup instead of leaving a process on an unknown * schema or connection state. */ -export async function applyDatabaseMigrations(database: MigrationDatabase): Promise { +export async function applyDatabaseMigrations( + database: MigrationDatabase, + options: MigrationGateOptions = {}, +): Promise { await database.raw('PRAGMA foreign_keys = OFF'); let migrationFailed = false; let migrationFailure: unknown; try { - await database.migrate.latest(); + await migrateWithLockRetry(database, options); } catch (error) { migrationFailed = true; migrationFailure = error; diff --git a/packages/core/src/db/migrations/20260802010000_add_notification_preference_apis.js b/packages/core/src/db/migrations/20260802010000_add_notification_preference_apis.js index 26a4a7799..96e7c4209 100644 --- a/packages/core/src/db/migrations/20260802010000_add_notification_preference_apis.js +++ b/packages/core/src/db/migrations/20260802010000_add_notification_preference_apis.js @@ -1142,10 +1142,44 @@ async function hasLocalhostPushEndpoints(knex) { } } +function findIntroducedForeignKeyViolations(before, after) { + const remainingBaselineViolations = new Map(); + for (const violation of before) { + const identity = JSON.stringify([ + violation.table, + violation.rowid ?? null, + violation.parent, + violation.fkid, + ]); + remainingBaselineViolations.set( + identity, + (remainingBaselineViolations.get(identity) || 0) + 1 + ); + } + + return after.filter((violation) => { + const identity = JSON.stringify([ + violation.table, + violation.rowid ?? null, + violation.parent, + violation.fkid, + ]); + const baselineCount = remainingBaselineViolations.get(identity) || 0; + if (baselineCount === 0) return true; + if (baselineCount === 1) remainingBaselineViolations.delete(identity); + else remainingBaselineViolations.set(identity, baselineCount - 1); + return false; + }); +} + async function withForeignKeysDisabled(knex, operation) { const connection = await knex.client.acquireConnection(); const raw = (sql) => knex.raw(sql).connection(connection); try { + // A legacy database can contain unrelated violations from older schemas. + // Preserve that existing state without allowing this rebuild to add any new + // violations of its own. + const baselineViolations = await raw('PRAGMA foreign_key_check'); const rows = await raw('PRAGMA foreign_keys'); const foreignKeysEnabled = rows[0]?.foreign_keys === 1; if (foreignKeysEnabled) await raw('PRAGMA foreign_keys = OFF'); @@ -1157,7 +1191,11 @@ async function withForeignKeysDisabled(knex, operation) { async (transaction) => { await operation(transaction); const violations = await transaction.raw('PRAGMA foreign_key_check'); - if (violations.length > 0) { + const introducedViolations = findIntroducedForeignKeyViolations( + baselineViolations, + violations + ); + if (introducedViolations.length > 0) { throw new Error( 'Foreign-key violations detected after rebuilding push subscriptions' ); diff --git a/packages/core/src/db/migrations/20260829000000_add_notification_system_failure_state.js b/packages/core/src/db/migrations/20260829000000_add_notification_system_failure_state.js new file mode 100644 index 000000000..ca9d605b1 --- /dev/null +++ b/packages/core/src/db/migrations/20260829000000_add_notification_system_failure_state.js @@ -0,0 +1,44 @@ +/** + * Persist the latest system-health transition so notification projection is + * consistent across API restarts and multiple API instances. + */ + +const ISO_TIMESTAMP_CHECK = (column) => ` + typeof(${column}) = 'text' + AND ${column} GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9].[0-9][0-9][0-9]Z' + AND strftime('%Y-%m-%dT%H:%M:%fZ', ${column}) = ${column} +`; + +export async function up(knex) { + await knex.schema.createTable('notification_system_failure_state', (table) => { + table.text('component').notNullable().primary(); + table.text('failure_status').nullable(); + table.text('failure_started_at').nullable(); + table.text('last_snapshot_at').notNullable(); + + table.check( + `length(CAST(component AS BLOB)) BETWEEN 1 AND 255 + AND (failure_status IS NULL OR length(CAST(failure_status AS BLOB)) BETWEEN 1 AND 255)`, + {}, + 'notification_system_failure_state_text_check' + ); + table.check( + '(failure_status IS NULL) = (failure_started_at IS NULL)', + {}, + 'notification_system_failure_state_transition_check' + ); + table.check( + `${ISO_TIMESTAMP_CHECK('last_snapshot_at')} + AND (failure_started_at IS NULL OR ( + ${ISO_TIMESTAMP_CHECK('failure_started_at')} + AND failure_started_at <= last_snapshot_at + ))`, + {}, + 'notification_system_failure_state_timestamp_check' + ); + }); +} + +export async function down(knex) { + await knex.schema.dropTableIfExists('notification_system_failure_state'); +} diff --git a/packages/core/src/db/migrations/20260829010000_add_notification_pull_request_state.js b/packages/core/src/db/migrations/20260829010000_add_notification_pull_request_state.js new file mode 100644 index 000000000..0cf56c448 --- /dev/null +++ b/packages/core/src/db/migrations/20260829010000_add_notification_pull_request_state.js @@ -0,0 +1,37 @@ +/** + * Record merged pull requests before dismissing their Inbox receipts. The + * marker is authoritative for notification producers, so delayed projections + * cannot recreate actionable cards after a merge webhook has been handled. + */ + +const ISO_TIMESTAMP_CHECK = (column) => ` + typeof(${column}) = 'text' + AND ${column} GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9].[0-9][0-9][0-9]Z' + AND strftime('%Y-%m-%dT%H:%M:%fZ', ${column}) = ${column} +`; + +export async function up(knex) { + await knex.schema.createTable('notification_pull_request_state', (table) => { + table.text('repository').notNullable(); + table.integer('pr_number').notNullable(); + table.text('merged_at').nullable(); + + table.primary(['repository', 'pr_number']); + table.check( + `length(CAST(repository AS BLOB)) BETWEEN 1 AND 255 + AND repository GLOB '*/*' + AND pr_number BETWEEN 1 AND 9007199254740991`, + {}, + 'notification_pull_request_state_identity_check' + ); + table.check( + `merged_at IS NULL OR (${ISO_TIMESTAMP_CHECK('merged_at')})`, + {}, + 'notification_pull_request_state_timestamp_check' + ); + }); +} + +export async function down(knex) { + await knex.schema.dropTableIfExists('notification_pull_request_state'); +} diff --git a/packages/core/src/db/migrations/20260830000000_create_synthetic_routing_cursors.js b/packages/core/src/db/migrations/20260830000000_create_synthetic_routing_cursors.js new file mode 100644 index 000000000..c79059ca2 --- /dev/null +++ b/packages/core/src/db/migrations/20260830000000_create_synthetic_routing_cursors.js @@ -0,0 +1,18 @@ +/** + * Persisted cursors used by synthetic-agent round-robin selection. + * + * Keeping the counter in SQLite (rather than in a worker process) makes a + * synthetic pool rotate consistently when analysis, indexing, and task workers + * select concurrently. + */ +export async function up(knex) { + await knex.schema.createTable('synthetic_routing_cursors', table => { + table.string('synthetic_model_key', 255).primary(); + table.bigInteger('cursor').notNullable().defaultTo(0); + table.timestamp('updated_at').defaultTo(knex.fn.now()).notNullable(); + }); +} + +export async function down(knex) { + await knex.schema.dropTableIfExists('synthetic_routing_cursors'); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9efd78e4b..7846d92c0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -75,7 +75,7 @@ export { getEffectiveTokenLimit, getModelHardLimit, DEFAULT_CONTEXT_LEVEL, MIN_C export type { ContextLevel } from './config/modelLimits.js'; export { db, closeConnection, createKnexConfigForMigrations, runMigrations } from './db/connection.js'; -export { applyDatabaseMigrations, type MigrationDatabase } from './db/migrationGate.js'; +export { applyDatabaseMigrations, type MigrationDatabase, type MigrationGateOptions } from './db/migrationGate.js'; export { getRepoConfigKey, detectDefaultBranch, listRepositoryBranchConfigurations } from './git/branchConfig.js'; export type { BranchConfiguration } from './git/branchConfig.js'; @@ -125,9 +125,9 @@ export type { AutoResolveContext } from './queue/taskQueue.js'; -export { areAllChecksPassing, buildRedisRuntimeConfig, closeUltrafixStateRedis, getCurrentPRHead, getCheckRunsStatus, getActiveTasksForPR, hasActiveTasksForPR } from './webhook/checkRunHelpers.js'; -export type { CheckRunsStatus, ActivePRWork, ActivePRTask, ActivePRQueuedJob } from './webhook/checkRunHelpers.js'; +export { areAllChecksPassing, buildRedisRuntimeConfig, closeUltrafixStateRedis, getCurrentPRHead, getCheckRunsStatus, getActiveTasksForPR, hasActiveTasksForPR, type CheckRunsStatus, type ActivePRWork, type ActivePRTask, type ActivePRQueuedJob } from './webhook/checkRunHelpers.js'; export { handleCheckRunEvent, handleStatusEvent, reevaluatePRAutoMerge, setUltrafixCheckRunHook, type StatusEventPayload } from './webhook/checkRunHandler.js'; +export * from './webhook/ciFailureFollowup.js'; export { processWebhookEvent, initializeWebhookHandler, SUPPORTED_WEBHOOK_EVENTS } from './webhook/webhookHandler.js'; export type { WebhookEventType, DetectedIssue, IssueProcessor, CommentProcessor, CommentDeletedHandler, CommentEditedHandler, CheckRunProcessor, WebhookHandlerOptions } from './webhook/webhookHandler.js'; export { RoutingWebSocketIntakeService } from './intake/RoutingWebSocketIntakeService.js'; @@ -295,7 +295,7 @@ export type { export { getReposFromEnv, getRepos, - isMonitoredRepository, + isMonitoredRepository, isAutoCiFollowupEnabledForRepository, resolveMonitoredRepositories, getAiPrimaryTag, getPrimaryProcessingLabels, @@ -312,8 +312,8 @@ export { export { processDetectedIssue, fetchIssuesForRepo } from './daemon/issueDetection.js'; // Agent abstraction exports -export { AgentRegistry, getAgentRegistry } from './agents/AgentRegistry.js'; -export type { AgentRegistryOperationalStatus } from './agents/AgentRegistry.js'; +export { AgentRegistry, getAgentRegistry, type AgentRegistryOperationalStatus } from './agents/AgentRegistry.js'; +export * from './agents/syntheticRouting.js'; export { describeAgentTermination, isIncompleteAgentExecution, resolveAgentTerminationReason } from './agents/termination.js'; export { ClaudeAgent } from './agents/impl/ClaudeAgent.js'; export { CodexAgent } from './agents/impl/CodexAgent.js'; @@ -412,10 +412,10 @@ export { MAX_ACTIVE_PUSH_SUBSCRIPTIONS_PER_USER, MAX_STORED_PUSH_SUBSCRIPTIONS_PER_USER, MAX_PUSH_SUBSCRIPTION_ENROLLMENTS_PER_WINDOW, PUSH_SUBSCRIPTION_ENROLLMENT_WINDOW_MS, PUSH_SUBSCRIPTION_REVOKED_RETENTION_MS, PUSH_SUBSCRIPTION_GC_BATCH_SIZE, - notificationService, createNotificationEvent, assignNotificationRecipients, - listNotifications, getUnreadNotificationCount, markNotificationRead, dismissNotification, - getNotificationPreferences, updateNotificationPreferences, updateNotificationPreference, - upsertPushSubscription, listPushSubscriptions, revokePushSubscription, revokePushSubscriptionById, + notificationService, createNotificationEvent, assignNotificationRecipients, listNotifications, + getUnreadNotificationCount, markNotificationRead, dismissNotification, dismissAllNotifications, dismissNotificationReceipts, + dismissNotificationsForPullRequest, dismissSupersededPullRequestAttentionNotifications, dismissSystemFailureNotifications, + getNotificationPreferences, updateNotificationPreferences, updateNotificationPreference, upsertPushSubscription, listPushSubscriptions, revokePushSubscription, revokePushSubscriptionById, garbageCollectPushSubscriptions } from './services/notificationService.js'; export type { NotificationRecipientInput, NotificationRecipient, CreateNotificationEventInput, NotificationListOptions, NotificationServiceOptions } from './services/notificationService.js'; @@ -424,8 +424,7 @@ export type { NotificationCursor } from './services/notificationPagination.js'; // Repository migration (rename/move detection) export { - detectRepositoryRename, - migrateRepositoryReferences, + detectRepositoryRename, migrateRepositoryReferences, checkAndMigrateRepository, detectRenameFromResponse, scheduleRepositoryRenameCheck diff --git a/packages/core/src/services/notificationService.ts b/packages/core/src/services/notificationService.ts index 88fd11504..ad01155d8 100644 --- a/packages/core/src/services/notificationService.ts +++ b/packages/core/src/services/notificationService.ts @@ -14,6 +14,7 @@ import { parseNotificationPreferencesResponse, parseNotificationPreferencesUpdate, parseNotificationStateResponse, + parseNotificationUnreadCountResponse, type ISO8601Timestamp, type JsonObject, type Notification, @@ -27,6 +28,7 @@ import { type NotificationPreferencesUpdate, type NotificationSeverity, type NotificationStateResponse, + type NotificationUnreadCountResponse, type NotificationTargetFor, type PushSubscription, type PushSubscriptionInput @@ -98,6 +100,23 @@ export interface NotificationServiceOptions extends PushSubscriptionPolicyOption generateId?: () => string; } +export interface SystemFailureTransitionInput { + component: string; + status: string; + healthy: boolean; + snapshotAt: TimestampInput; + eventFor: ( + status: string, + failureStartedAt: ISO8601Timestamp + ) => CreateNotificationEventInput<'system_failure'> + | Promise>; +} + +export interface SystemFailureTransitionResult { + accepted: boolean; + event: NotificationEvent<'system_failure'> | null; +} + interface NotificationEventRow { event_id: string; deduplication_key: string; @@ -134,6 +153,13 @@ interface NotificationPreferenceSettingsRow { badge_enabled: number | boolean; } +interface SystemFailureStateRow { + component: string; + failure_status: string | null; + failure_started_at: string | null; + last_snapshot_at: string; +} + interface NormalizedRecipient { userId: string; inboxEnabled: boolean; @@ -202,6 +228,15 @@ function validateNotificationInput(parser: () => T): T { } } +function isContinuingSystemFailure( + existing: SystemFailureStateRow | undefined, + input: SystemFailureTransitionInput +): existing is SystemFailureStateRow & { failure_started_at: string } { + return !input.healthy + && existing?.failure_status === input.status + && typeof existing.failure_started_at === 'string'; +} + function assertIdentifier(value: string, path: string): void { // Reuse the durable event parser's identifier constraints without exposing // unbounded values to SQLite. User IDs come from trusted auth or workers. @@ -310,12 +345,215 @@ export class NotificationService { input: CreateNotificationEventInput, recipients: readonly NotificationRecipient[] = input.recipients ?? [] ): Promise> { + const event = this.prepareNotificationEvent(input); + const normalizedRecipients = normalizeRecipients(recipients); + + return this.database.transaction(transaction => + this.persistNotificationEvent(transaction, event, normalizedRecipients)); + } + + /** + * Create a PR-related event only while the durable PR lifecycle says the + * pull request is still open. This check shares the event transaction with + * merge marking, so either creation commits first and merge dismisses it, + * or the merge marker commits first and creation is skipped. + */ + async createPullRequestNotificationEvent( + repository: string, + prNumber: number, + input: CreateNotificationEventInput, + recipients: readonly NotificationRecipient[] = input.recipients ?? [] + ): Promise | null> { + this.assertPullRequestIdentity(repository, prNumber); + const event = this.prepareNotificationEvent(input); + const normalizedRecipients = normalizeRecipients(recipients); + + return this.database.transaction(async transaction => { + if (!await this.pullRequestIsOpen(transaction, repository, prNumber)) return null; + return this.persistNotificationEvent(transaction, event, normalizedRecipients); + }); + } + + /** + * Create or reuse a PR-attention event and supersede older cards in the + * same transaction that checks the durable merge marker. + */ + async createPullRequestAttentionNotificationEvent( + repository: string, + prNumber: number, + input: CreateNotificationEventInput<'pull_request'>, + recipients: readonly NotificationRecipient[] = input.recipients ?? [] + ): Promise | null> { + this.assertPullRequestIdentity(repository, prNumber); + const event = this.prepareNotificationEvent(input); + const normalizedRecipients = normalizeRecipients(recipients); + + return this.database.transaction(async transaction => { + if (!await this.pullRequestIsOpen(transaction, repository, prNumber)) return null; + const storedEvent = await this.persistNotificationEvent( + transaction, + event, + normalizedRecipients + ); + const matching = () => this.matchingPullRequestAttentionEvents( + transaction, + repository, + prNumber + ); + const newest = await matching() + .select('event.event_id') + .orderBy('event.occurred_at', 'desc') + .orderBy('event.event_id', 'desc') + .first() as { event_id: string } | undefined; + if (newest) { + await this.dismissReceiptQuery( + matching().select('event.event_id').whereNot({ + 'event.event_id': newest.event_id + }), + transaction + ); + } + return storedEvent; + }); + } + + /** + * Commit one system-health transition together with receipt supersession + * and current-event creation. After bootstrap, only the event belonging to + * the transition being replaced is dismissed, so stale instances never run + * a component-wide receipt update. + */ + async reconcileSystemFailureTransition( + input: SystemFailureTransitionInput, + recipients: readonly NotificationRecipient[] = [] + ): Promise { + assertIdentifier(input.component, 'notification system component'); + assertIdentifier(input.status, 'notification system status'); + const snapshotAt = normalizeISO8601Timestamp(input.snapshotAt); + const normalizedRecipients = normalizeRecipients(recipients); + + return this.database.transaction(async transaction => { + // Acquire SQLite's write reservation before reading. Concurrent + // instances therefore observe transitions in commit order instead + // of both reading the same pre-transition snapshot. + const inserted = await transaction('notification_system_failure_state') + .insert({ + component: input.component, + failure_status: input.healthy ? null : input.status, + failure_started_at: input.healthy ? null : snapshotAt, + last_snapshot_at: snapshotAt + }) + .onConflict('component') + .ignore() + .returning('component') as Array<{ component: string }>; + const initializing = inserted.length > 0; + const existing = await transaction( + 'notification_system_failure_state' + ) + .where({ component: input.component }) + .first(); + if (existing && snapshotAt < existing.last_snapshot_at) { + return { accepted: false, event: null }; + } + if (initializing) { + return this.reconcileInitialSystemFailureReceipts( + transaction, + input, + snapshotAt, + normalizedRecipients + ); + } + + const continuingFailure = isContinuingSystemFailure(existing, input); + const failureStartedAt = input.healthy + ? null + : continuingFailure ? existing.failure_started_at : snapshotAt; + await transaction('notification_system_failure_state') + .insert({ + component: input.component, + failure_status: input.healthy ? null : input.status, + failure_started_at: failureStartedAt, + last_snapshot_at: snapshotAt + }) + .onConflict('component') + .merge({ + failure_status: input.healthy ? null : input.status, + failure_started_at: failureStartedAt, + last_snapshot_at: snapshotAt + }); + + if (!continuingFailure + && existing?.failure_status !== null + && typeof existing?.failure_status === 'string' + && typeof existing.failure_started_at === 'string' + ) { + const superseded = await input.eventFor( + existing.failure_status, + existing.failure_started_at as ISO8601Timestamp + ); + await this.dismissReceiptQuery( + transaction('notification_events') + .select('event_id') + .where({ deduplication_key: superseded.deduplicationKey }), + transaction + ); + } + + if (input.healthy || failureStartedAt === null) { + return { accepted: true, event: null }; + } + const event = this.prepareNotificationEvent(await input.eventFor( + input.status, + failureStartedAt as ISO8601Timestamp + )); + return { + accepted: true, + event: await this.persistNotificationEvent( + transaction, + event, + normalizedRecipients + ) + }; + }); + } + + private async reconcileInitialSystemFailureReceipts( + transaction: Knex.Transaction, + input: SystemFailureTransitionInput, + failureStartedAt: ISO8601Timestamp, + normalizedRecipients: NormalizedRecipient[] + ): Promise { + let priorEvents = this.matchingTargetEvents(['system_failure'], transaction) + .whereRaw("json_extract(event.target_json, '$.component') = ?", [input.component]); + if (input.healthy) { + await this.dismissReceiptQuery(priorEvents, transaction); + return { accepted: true, event: null }; + } + const eventInput = await input.eventFor(input.status, failureStartedAt); + const currentEvent = this.prepareNotificationEvent(eventInput); + priorEvents = priorEvents.whereNot({ + 'event.deduplication_key': currentEvent.deduplicationKey + }); + await this.dismissReceiptQuery(priorEvents, transaction); + return { + accepted: true, + event: await this.persistNotificationEvent( + transaction, + currentEvent, + normalizedRecipients + ) + }; + } + + private prepareNotificationEvent( + input: CreateNotificationEventInput + ): NotificationEvent { if (input.id !== undefined && input.eventId !== undefined && input.id !== input.eventId) { throw new TypeError('notification id and eventId must match when both are supplied'); } const createdAt = normalizeISO8601Timestamp(this.now()); - const event = parseNotificationEvent({ + return parseNotificationEvent({ id: input.eventId ?? input.id ?? this.generateId(), deduplicationKey: input.deduplicationKey, kind: input.kind, @@ -331,39 +569,40 @@ export class NotificationService { : normalizeISO8601Timestamp(input.occurredAt), createdAt }) as NotificationEvent; - const normalizedRecipients = normalizeRecipients(recipients); + } - return this.database.transaction(async (transaction) => { - await transaction('notification_events') - .insert({ - event_id: event.id, - deduplication_key: event.deduplicationKey, - kind: event.kind, - severity: event.severity, - target_json: JSON.stringify(event.target), - title: event.title, - body: event.body, - action_json: event.action === undefined ? null : JSON.stringify(event.action), - advertised_actions_json: JSON.stringify(event.actions), - metadata_json: event.metadata === undefined - ? null - : JSON.stringify(event.metadata), - occurred_at: event.occurredAt, - created_at: event.createdAt - }) - .onConflict('deduplication_key') - .ignore(); + private async persistNotificationEvent( + transaction: Knex.Transaction, + event: NotificationEvent, + normalizedRecipients: NormalizedRecipient[] + ): Promise> { + await transaction('notification_events') + .insert({ + event_id: event.id, + deduplication_key: event.deduplicationKey, + kind: event.kind, + severity: event.severity, + target_json: JSON.stringify(event.target), + title: event.title, + body: event.body, + action_json: event.action === undefined ? null : JSON.stringify(event.action), + advertised_actions_json: JSON.stringify(event.actions), + metadata_json: event.metadata === undefined + ? null + : JSON.stringify(event.metadata), + occurred_at: event.occurredAt, + created_at: event.createdAt + }) + .onConflict('deduplication_key') + .ignore(); - const storedRow = await transaction('notification_events') - .where({ deduplication_key: event.deduplicationKey }) - .first(); - if (!storedRow) { - throw new Error('Notification event was not persisted'); - } - const storedEvent = toNotificationEvent(storedRow) as NotificationEvent; - await this.assignRecipients(transaction, storedEvent, normalizedRecipients); - return storedEvent; - }); + const storedRow = await transaction('notification_events') + .where({ deduplication_key: event.deduplicationKey }) + .first(); + if (!storedRow) throw new Error('Notification event was not persisted'); + const storedEvent = toNotificationEvent(storedRow) as NotificationEvent; + await this.assignRecipients(transaction, storedEvent, normalizedRecipients); + return storedEvent; } async assignNotificationRecipients( @@ -540,6 +779,121 @@ export class NotificationService { return this.updateInboxTimestamp(userId, eventId, 'dismissed_at'); } + /** Dismiss every active Inbox receipt owned by one user. */ + async dismissAllNotifications( + userId: string + ): Promise { + assertIdentifier(userId, 'notification userId'); + const timestamp = normalizeISO8601Timestamp(this.now()); + + return this.database.transaction(async transaction => { + await transaction('notification_user_states') + .where({ user_id: userId, inbox_enabled: true }) + .whereNull('dismissed_at') + .update({ + dismissed_at: transaction.raw( + 'CASE WHEN created_at > ? THEN created_at ELSE ? END', + [timestamp, timestamp] + ) + }); + + return parseNotificationUnreadCountResponse({ + unreadCount: await unreadCount(transaction, userId) + }); + }); + } + + /** Dismiss every Inbox receipt for one immutable audit event. */ + async dismissNotificationReceipts(eventId: string): Promise { + assertIdentifier(eventId, 'notification eventId'); + return this.dismissReceiptQuery( + this.database('notification_events').select('event_id').where({ event_id: eventId }) + ); + } + + /** + * Close every Inbox card whose target is the given pull request. Audit + * events and push-delivery history remain untouched. + */ + async dismissNotificationsForPullRequest( + repository: string, + prNumber: number + ): Promise { + this.assertPullRequestIdentity(repository, prNumber); + return this.dismissReceiptQuery( + this.matchingTargetEvents(['task', 'review', 'pull_request']) + .whereRaw("json_extract(event.target_json, '$.repository') = ?", [repository]) + .whereRaw("json_extract(event.target_json, '$.prNumber') = ?", [prNumber]) + ); + } + + /** Persist a merged marker and close all existing PR receipts atomically. */ + async markPullRequestMergedAndDismissNotifications( + repository: string, + prNumber: number, + mergedAt: TimestampInput = this.now() + ): Promise { + this.assertPullRequestIdentity(repository, prNumber); + const normalizedMergedAt = normalizeISO8601Timestamp(mergedAt); + + return this.database.transaction(async transaction => { + await transaction('notification_pull_request_state') + .insert({ + repository, + pr_number: prNumber, + merged_at: normalizedMergedAt + }) + .onConflict(['repository', 'pr_number']) + .merge({ merged_at: normalizedMergedAt }); + return this.dismissReceiptQuery( + this.matchingTargetEvents( + ['task', 'review', 'pull_request'], + transaction + ) + .whereRaw("json_extract(event.target_json, '$.repository') = ?", [repository]) + .whereRaw("json_extract(event.target_json, '$.prNumber') = ?", [prNumber]), + transaction + ); + }); + } + + /** Keep only the newest PR-attention event visible for a repository/PR. */ + async dismissSupersededPullRequestAttentionNotifications( + repository: string, + prNumber: number + ): Promise { + this.assertPullRequestIdentity(repository, prNumber); + + return this.database.transaction(async (transaction) => { + const matching = () => transaction('notification_events as event') + .where({ 'event.kind': 'pull_request' }) + .whereRaw("json_extract(event.target_json, '$.repository') = ?", [repository]) + .whereRaw("json_extract(event.target_json, '$.prNumber') = ?", [prNumber]); + const newest = await matching() + .select('event.event_id') + .orderBy('event.occurred_at', 'desc') + .orderBy('event.event_id', 'desc') + .first() as { event_id: string } | undefined; + if (!newest) return 0; + + return this.dismissReceiptQuery( + matching().select('event.event_id').whereNot({ + 'event.event_id': newest.event_id + }), + transaction + ); + }); + } + + /** Dismiss active failure cards for one system-health component. */ + async dismissSystemFailureNotifications(component: string): Promise { + assertIdentifier(component, 'notification system component'); + return this.dismissReceiptQuery( + this.matchingTargetEvents(['system_failure']) + .whereRaw("json_extract(event.target_json, '$.component') = ?", [component]) + ); + } + private async readPreferenceSnapshot( database: Database, userId: string @@ -727,6 +1081,70 @@ export class NotificationService { } } + private assertPullRequestIdentity(repository: string, prNumber: number): void { + assertIdentifier(repository, 'notification repository'); + if (!Number.isSafeInteger(prNumber) || prNumber <= 0) { + throw new TypeError('notification prNumber must be a positive safe integer'); + } + } + + private async pullRequestIsOpen( + transaction: Knex.Transaction, + repository: string, + prNumber: number + ): Promise { + // The insert is also the per-database write barrier. It prevents a + // merge transaction from committing between this guard and event + // persistence on SQLite's otherwise deferred transactions. + await transaction('notification_pull_request_state') + .insert({ repository, pr_number: prNumber, merged_at: null }) + .onConflict(['repository', 'pr_number']) + .ignore(); + const state = await transaction('notification_pull_request_state') + .select('merged_at') + .where({ repository, pr_number: prNumber }) + .first() as { merged_at?: unknown } | undefined; + return typeof state?.merged_at !== 'string'; + } + + private matchingPullRequestAttentionEvents( + database: Database, + repository: string, + prNumber: number + ): Knex.QueryBuilder { + return database('notification_events as event') + .where({ 'event.kind': 'pull_request' }) + .whereRaw("json_extract(event.target_json, '$.repository') = ?", [repository]) + .whereRaw("json_extract(event.target_json, '$.prNumber') = ?", [prNumber]); + } + + private matchingTargetEvents( + kinds: readonly NotificationKind[], + database: Database = this.database + ): Knex.QueryBuilder { + return database('notification_events as event') + .select('event.event_id') + .whereIn('event.kind', kinds); + } + + private async dismissReceiptQuery( + eventIds: Knex.QueryBuilder, + database: Database = this.database + ): Promise { + const timestamp = normalizeISO8601Timestamp(this.now()); + const changed = await database('notification_user_states') + .where({ inbox_enabled: true }) + .whereNull('dismissed_at') + .whereIn('event_id', eventIds) + .update({ + dismissed_at: database.raw( + 'CASE WHEN created_at > ? THEN created_at ELSE ? END', + [timestamp, timestamp] + ) + }); + return Number(changed); + } + private async updateInboxTimestamp( userId: string, eventId: string, @@ -799,6 +1217,19 @@ export const markNotificationRead = notificationService.markNotificationRead .bind(notificationService) as NotificationService['markNotificationRead']; export const dismissNotification = notificationService.dismissNotification .bind(notificationService) as NotificationService['dismissNotification']; +export const dismissAllNotifications = notificationService.dismissAllNotifications + .bind(notificationService) as NotificationService['dismissAllNotifications']; +export const dismissNotificationReceipts = notificationService.dismissNotificationReceipts + .bind(notificationService) as NotificationService['dismissNotificationReceipts']; +export const dismissNotificationsForPullRequest = notificationService + .dismissNotificationsForPullRequest + .bind(notificationService) as NotificationService['dismissNotificationsForPullRequest']; +export const dismissSupersededPullRequestAttentionNotifications = notificationService + .dismissSupersededPullRequestAttentionNotifications + .bind(notificationService) as NotificationService['dismissSupersededPullRequestAttentionNotifications']; +export const dismissSystemFailureNotifications = notificationService + .dismissSystemFailureNotifications + .bind(notificationService) as NotificationService['dismissSystemFailureNotifications']; export const getNotificationPreferences = notificationService.getNotificationPreferences .bind(notificationService) as NotificationService['getNotificationPreferences']; export const updateNotificationPreferences = notificationService.updateNotificationPreferences diff --git a/packages/core/src/services/planning/planningTypes.ts b/packages/core/src/services/planning/planningTypes.ts index a3a65b3a4..34674c3ef 100644 --- a/packages/core/src/services/planning/planningTypes.ts +++ b/packages/core/src/services/planning/planningTypes.ts @@ -8,6 +8,7 @@ import { CODEX_CLI_CONTEXT_LIMIT } from '../../config/modelLimits.js'; import type { ContextLevel } from '../../config/modelLimits.js'; import type { Attachment } from '../attachmentService.js'; import type { StepStatus } from '@propr/shared'; +import type { SyntheticRoutingSession } from '../syntheticRoutingService.js'; /** Reserved overhead for system prompts, XML structure, etc. */ export const RESERVED_OVERHEAD_TOKENS = 5000; @@ -197,6 +198,7 @@ export interface FindFilesOptions { autoFiles: string[]; correlationId?: string; contextModel?: string; + routingSession?: SyntheticRoutingSession; } export interface TaskDraftForFind { diff --git a/packages/core/src/services/planning/planningUtils.ts b/packages/core/src/services/planning/planningUtils.ts index 065d5c3c7..2a53595c9 100644 --- a/packages/core/src/services/planning/planningUtils.ts +++ b/packages/core/src/services/planning/planningUtils.ts @@ -117,7 +117,7 @@ export async function calculateCostEstimate( } export async function findFilesForPlan(opts: FindFilesOptions): Promise { - const { worktreePath, draft, manualFiles, autoFiles, correlationId, contextModel } = opts; + const { worktreePath, draft, manualFiles, autoFiles, correlationId, contextModel, routingSession } = opts; const correlatedLogger = correlationId ? logger.withCorrelation(correlationId) : logger; const fileRefResult = await parseFileReferences(draft.initial_prompt, worktreePath, { correlationId }); @@ -141,7 +141,7 @@ export async function findFilesForPlan(opts: FindFilesOptions): Promise f.path); diff --git a/packages/core/src/services/relevance/contextAnalysisConfig.ts b/packages/core/src/services/relevance/contextAnalysisConfig.ts index f7102c62d..24d767092 100644 --- a/packages/core/src/services/relevance/contextAnalysisConfig.ts +++ b/packages/core/src/services/relevance/contextAnalysisConfig.ts @@ -1,4 +1,4 @@ -export const DEFAULT_CONTEXT_ANALYSIS_TIMEOUT_MS = 30 * 60 * 1000; +export const DEFAULT_CONTEXT_ANALYSIS_TIMEOUT_MS = 60 * 60 * 1000; /** Resolve the relevance-analysis deadline, falling back safely on invalid input. */ export function resolveContextAnalysisTimeoutMs( diff --git a/packages/core/src/services/relevance/keywordExtractor.ts b/packages/core/src/services/relevance/keywordExtractor.ts index 9a6133196..a6dd90f72 100644 --- a/packages/core/src/services/relevance/keywordExtractor.ts +++ b/packages/core/src/services/relevance/keywordExtractor.ts @@ -4,6 +4,7 @@ import logger from '../../utils/logger.js'; import { persistLlmLog, createLlmLogFromAnalysis } from '../../utils/llmLogger.js'; import { loadSettings } from '../../config/configManager.js'; import { resolveContextAnalysisTimeoutMs } from './contextAnalysisConfig.js'; +import type { SyntheticRoutingSession } from '../syntheticRoutingService.js'; // --- Settings cache (avoids a DB round-trip on every LLM extraction call) --- @@ -191,6 +192,7 @@ export interface KeywordExtractionOptions { /** Agent to use for LLM calls */ agent: Agent; correlationId?: string; + routingSession?: SyntheticRoutingSession; } const KEYWORD_EXTRACTION_PROMPT = `Extract the most relevant keywords from the user's request for finding files in a codebase. @@ -211,6 +213,25 @@ Return ONLY a JSON object in this exact format: "alternatives": ["alt1", "alt2", "related1"] }`; +function resolveKeywordLogTarget(options: { + actualModelUsed?: string; + routedMetadata?: Record; + configuredModel?: string; + agent: Agent; +}): { modelUsed: string; agentAlias: string } { + const { actualModelUsed, routedMetadata, configuredModel, agent } = options; + const routedModel = routedMetadata?.physicalModel; + const routedAgentAlias = routedMetadata?.physicalAgentAlias; + return { + modelUsed: actualModelUsed + || (typeof routedModel === 'string' ? routedModel : undefined) + || configuredModel + || agent.config.defaultModel + || 'unknown', + agentAlias: typeof routedAgentAlias === 'string' ? routedAgentAlias : agent.config.alias, + }; +} + /** * Extracts relevant keywords and alternatives from a user prompt using an LLM. * This helps improve file matching by understanding the user's intent. @@ -219,12 +240,14 @@ export async function extractKeywordsWithLLM( prompt: string, options: KeywordExtractionOptions ): Promise { - const { agent, correlationId } = options; + const { agent, correlationId, routingSession } = options; const correlatedLogger = correlationId ? logger.withCorrelation(correlationId) : logger; const startTime = Date.now(); let success = false; let errorMessage: string | undefined; + let routedMetadata: Record | undefined; + let actualModelUsed: string | undefined; const cachedSettings = await getCachedSettings(); try { @@ -234,14 +257,19 @@ export async function extractKeywordsWithLLM( correlatedLogger.debug({ promptLength: prompt.length, model: contextModel }, 'Extracting keywords with LLM'); - const analysisResult = await agent.analyze(llmPrompt, { + const analyzeOptions = { ...(contextModel ? { model: contextModel } : {}), timeoutMs: resolveContextAnalysisTimeoutMs(), executionType: 'context-analysis', correlationId, metadata: { callType: 'keyword_extraction' }, suppressLlmLog: true - }); + }; + const analysisResult = routingSession + ? await routingSession.analyze(llmPrompt, analyzeOptions) + : await agent.analyze(llmPrompt, analyzeOptions); + routedMetadata = routingSession?.routingMetadata; + actualModelUsed = analysisResult.modelUsed; if (!analysisResult.success) { throw new Error(analysisResult.error || 'Context keyword analysis failed'); } @@ -284,18 +312,27 @@ export async function extractKeywordsWithLLM( return { primary: [], alternatives: [], all: [] }; } finally { const durationMs = Date.now() - startTime; - const modelUsed = cachedSettings.planner_context_model as string || agent.config.defaultModel || 'unknown'; + routedMetadata ??= routingSession?.routingMetadata; + const logTarget = resolveKeywordLogTarget({ + actualModelUsed, + routedMetadata, + configuredModel: cachedSettings.planner_context_model as string, + agent, + }); // Persist to llm_logs table const logEntry = createLlmLogFromAnalysis({ executionType: 'context-analysis', - modelUsed, + modelUsed: logTarget.modelUsed, executionTimeMs: durationMs, success, error: errorMessage, correlationId, - agentAlias: agent.config.alias, - metadata: { callType: 'keyword_extraction' }, + agentAlias: logTarget.agentAlias, + metadata: { + callType: 'keyword_extraction', + ...(routedMetadata && { syntheticRouting: routedMetadata }), + }, workRef: { workType: 'repository', }, diff --git a/packages/core/src/services/relevance/semanticScorer.ts b/packages/core/src/services/relevance/semanticScorer.ts index f81403fbf..4e6c86f9f 100644 --- a/packages/core/src/services/relevance/semanticScorer.ts +++ b/packages/core/src/services/relevance/semanticScorer.ts @@ -5,6 +5,7 @@ import { logSummarizationCall } from './summaryMinerMetrics.js'; import { MODEL_INFO_MAP } from '../../config/modelDefinitions.js'; import { persistLlmLog, createLlmLogFromAnalysis } from '../../utils/llmLogger.js'; import { resolveContextAnalysisTimeoutMs } from './contextAnalysisConfig.js'; +import type { SyntheticRoutingSession } from '../syntheticRoutingService.js'; // --- Types --- @@ -27,6 +28,7 @@ export interface SemanticScoringOptions { repoName?: string; /** Branch to filter summaries (e.g., "HEAD", "main", "dev") */ branch?: string; + routingSession?: SyntheticRoutingSession; } export interface SemanticLLMFile { @@ -75,6 +77,24 @@ function getMaxChunkTokens(modelId?: string): number { return DEFAULT_MAX_CHUNK_TOKENS; } +function routedAgentAlias(metadata: Record | undefined, fallback: string): string { + return typeof metadata?.physicalAgentAlias === 'string' ? metadata.physicalAgentAlias : fallback; +} + +function resolvedSemanticModel( + actualModelUsed: string | undefined, + routedMetadata: Record | undefined, + configuredModel: string | undefined, + defaultModel: string | undefined +): string { + const routedModel = routedMetadata?.physicalModel; + return actualModelUsed + || (typeof routedModel === 'string' ? routedModel : undefined) + || configuredModel + || defaultModel + || 'unknown'; +} + // --- Main Export --- /** @@ -90,7 +110,7 @@ export async function scoreSemanticRelevance( userPrompt: string, options: SemanticScoringOptions ): Promise { - const { agent, correlationId, repoName, branch, modelId } = options; + const { agent, correlationId, repoName, branch, modelId, routingSession } = options; const correlatedLogger = correlationId ? logger.withCorrelation(correlationId) : logger; try { @@ -167,10 +187,15 @@ export async function scoreSemanticRelevance( const prompt = buildSemanticRankingPrompt(userPrompt, chunkContext); const estimatedInputTokens = Math.ceil(prompt.length / CHARS_PER_TOKEN_ESTIMATE); const estimatedOutputTokens = 500; + let routedMetadata: Record | undefined; + let actualModelUsed: string | undefined; + const callRoute = routingSession + ? (index === 0 ? routingSession : routingSession.fork()) + : undefined; try { // Pass modelId to use the configured context analysis model - const analysisResult = await agent.analyze(prompt, { + const analyzeOptions = { model: modelId, timeoutMs: resolveContextAnalysisTimeoutMs(), executionType: 'context-analysis', @@ -178,7 +203,12 @@ export async function scoreSemanticRelevance( repository: repoName, metadata: { callType: 'semantic_scoring', chunkIndex: index }, suppressLlmLog: true - }); + }; + const analysisResult = callRoute + ? await callRoute.analyze(prompt, analyzeOptions) + : await agent.analyze(prompt, analyzeOptions); + routedMetadata = callRoute?.routingMetadata; + actualModelUsed = analysisResult.modelUsed; if (!analysisResult.success) { throw new Error(analysisResult.error || `Semantic scoring chunk ${index} failed`); } @@ -186,14 +216,15 @@ export async function scoreSemanticRelevance( const parsed = parseSemanticResponse(response); const chunkDurationMs = Date.now() - startTime; - const modelUsed = modelId || agent.config.defaultModel || 'unknown'; + const modelUsed = resolvedSemanticModel(actualModelUsed, routedMetadata, modelId, agent.config.defaultModel); + const physicalAgentAlias = routedAgentAlias(routedMetadata, agent.config.alias); // Log metrics for this chunk await logSummarizationCall({ timestamp: new Date().toISOString(), callType: 'semantic_scoring', model: modelUsed, - agentAlias: agent.config.alias, + agentAlias: physicalAgentAlias, estimatedInputTokens, estimatedOutputTokens, estimatedTotalTokens: estimatedInputTokens + estimatedOutputTokens, @@ -213,8 +244,12 @@ export async function scoreSemanticRelevance( output_tokens: estimatedOutputTokens, }, correlationId, - agentAlias: agent.config.alias, - metadata: { callType: 'semantic_scoring', chunkIndex: index }, + agentAlias: physicalAgentAlias, + metadata: { + callType: 'semantic_scoring', + chunkIndex: index, + ...(routedMetadata && { syntheticRouting: routedMetadata }), + }, workRef: { workType: 'repository', workRepository: repoName, @@ -224,8 +259,10 @@ export async function scoreSemanticRelevance( return parsed.files; } catch (err) { + routedMetadata ??= callRoute?.routingMetadata; const chunkDurationMs = Date.now() - startTime; - const modelUsed = modelId || agent.config.defaultModel || 'unknown'; + const modelUsed = resolvedSemanticModel(actualModelUsed, routedMetadata, modelId, agent.config.defaultModel); + const physicalAgentAlias = routedAgentAlias(routedMetadata, agent.config.alias); const errorMessage = (err as Error).message; correlatedLogger.warn({ @@ -237,7 +274,7 @@ export async function scoreSemanticRelevance( timestamp: new Date().toISOString(), callType: 'semantic_scoring', model: modelUsed, - agentAlias: agent.config.alias, + agentAlias: physicalAgentAlias, estimatedInputTokens, estimatedOutputTokens, estimatedTotalTokens: estimatedInputTokens + estimatedOutputTokens, @@ -258,8 +295,12 @@ export async function scoreSemanticRelevance( }, error: errorMessage, correlationId, - agentAlias: agent.config.alias, - metadata: { callType: 'semantic_scoring', chunkIndex: index }, + agentAlias: physicalAgentAlias, + metadata: { + callType: 'semantic_scoring', + chunkIndex: index, + ...(routedMetadata && { syntheticRouting: routedMetadata }), + }, workRef: { workType: 'repository', workRepository: repoName, diff --git a/packages/core/src/services/relevance/summaryMinerBatch.ts b/packages/core/src/services/relevance/summaryMinerBatch.ts index c1b898a0d..d3395e97d 100644 --- a/packages/core/src/services/relevance/summaryMinerBatch.ts +++ b/packages/core/src/services/relevance/summaryMinerBatch.ts @@ -1,18 +1,15 @@ import type { Logger } from 'pino'; -import logger from '../../utils/logger.js'; -import { Agent } from '../../agents/types.js'; +import { Agent, type AnalyzeOptions } from '../../agents/types.js'; import { isQuotaExhaustionError, withRetry, type RetryOptions } from '../../utils/retryHandler.js'; -import { resolveExpectedSummaryPath } from './summaryMinerDirectoryHelpers.js'; import { saveBatchSummaries, logFileBatchCall, type SummaryResult } from './summaryMinerBatchPersistence.js'; -import { - clearSummarizationCooldown, - clearSummarizationPrimaryQuotaFailures, - isSummarizationInvalidResponseError, - promoteSummarizationFallbackIfNeeded, - recordPrimarySummarizationQuotaFailure, - recordPrimarySummarizationResponseFailure, - recordSummarizationCooldown -} from '../../config/configManager.js'; +import { clearSummarizationCooldown, clearSummarizationPrimaryQuotaFailures, isSummarizationInvalidResponseError, promoteSummarizationFallbackIfNeeded } from '../../config/configManager.js'; +import { recordPrimarySummarizationQuotaFailure, recordPrimarySummarizationResponseFailure, recordSummarizationCooldown } from '../../config/configManager.js'; +import { SyntheticPoolExhaustedError, type SyntheticRoutingSession } from '../syntheticRoutingService.js'; +import { SyntheticAgent } from '../../agents/SyntheticAgent.js'; +import { buildBatchPrompt, parseBatchResponse, type BatchFile } from './summaryMinerBatchHelpers.js'; + +export { DEFAULT_INSTRUCTIONS, parseBatchResponse } from './summaryMinerBatchHelpers.js'; +export type { BatchFile } from './summaryMinerBatchHelpers.js'; const CHARS_PER_TOKEN_ESTIMATE = 3; const SUMMARIZATION_RETRY_BASE_DELAY_MS = process.env.NODE_ENV === 'test' ? 0 : 2000; @@ -35,54 +32,32 @@ const SUMMARIZATION_FALLBACK_RETRY: RetryOptions = { retryableErrors: ['SUMMARIZATION_INVALID_RESPONSE'], }; -export interface BatchFile { - path: string; - content: string; - blobHash: string; -} - interface ProcessSingleBatchOptions { - fullName: string; - batch: BatchFile[]; - agent: Agent; - log: Logger; - modelUsed: string; - customPrompt?: string; - primaryAgentAliasSetting?: string; - fallbackAgent?: Agent; - fallbackModelOverride?: string; - fallbackModelUsed?: string; - fallbackAgentAliasSetting?: string; - branch: string; + fullName: string; batch: BatchFile[]; + agent: Agent; log: Logger; + modelUsed: string; customPrompt?: string; + primaryAgentAliasSetting?: string; fallbackAgent?: Agent; + fallbackModelOverride?: string; fallbackModelUsed?: string; + fallbackAgentAliasSetting?: string; branch: string; + routingSession?: SyntheticRoutingSession; + fallbackRoutingSession?: SyntheticRoutingSession; } export interface ProcessSingleBatchResult { - success: boolean; - fallbackUsed: boolean; - stopProcessing: boolean; - primaryAgentAlias?: string; - fallbackAgentAlias?: string; + success: boolean; fallbackUsed: boolean; stopProcessing: boolean; + primaryAgentAlias?: string; fallbackAgentAlias?: string; } -export const DEFAULT_INSTRUCTIONS = `You are a code expert. Analyze the following source code files. -For each file, provide a summary (3-4 sentences) covering: -1. Primary purpose of the file -2. Key functions, classes, or exports it provides -3. What other parts of the system it interacts with or depends on`; - -const JSON_FORMAT_RULES = `Return ONLY valid JSON in this exact format: -{ - "summaries": [ - { "path": "relative/path/to/file", "summary": "This file handles... It provides... It interacts with..." } - ] +interface BatchAnalysisResult { + results: SummaryResult[]; agentUsed: Agent; modelLogged: string; + routingMetadata?: Record; fallbackUsed: boolean; + primaryAgentAlias?: string; fallbackAgentAlias?: string; } -Important: -- Include ALL files listed below in your response -- Each summary should be 3-4 sentences with specific details -- Mention key function/class names when relevant -- Focus on what the file does and how it connects to the system -- Return valid JSON only, no markdown or other formatting`; +type BatchAnalysisOptions = ProcessSingleBatchOptions & { + prompt: string; + onFallbackAttempt: () => void; +}; class SummarizationCooldownRecordedError extends Error { constructor(error: unknown) { @@ -102,6 +77,10 @@ export async function processSingleBatch(options: ProcessSingleBatchOptions): Pr primaryAgentAliasSetting, fallbackAgent, fallbackModelOverride, fallbackModelUsed, fallbackAgentAliasSetting } = options; const prompt = buildBatchPrompt(batch, customPrompt); + const fallbackRoutingSession = beginFallbackRoutingSession( + fallbackAgent, + fallbackModelUsed ?? fallbackModelOverride + ); const startTime = Date.now(); const estimatedInputTokens = Math.ceil(prompt.length / CHARS_PER_TOKEN_ESTIMATE); const estimatedOutputTokens = batch.length * 120; @@ -113,30 +92,45 @@ export async function processSingleBatch(options: ProcessSingleBatchOptions): Pr let stopProcessing = false; let fallbackPrimaryAgentAlias: string | undefined; let fallbackAgentAlias: string | undefined; + let routingMetadata: Record | undefined; + let fallbackAttempted = false; try { const summaries = await analyzeBatchWithFallback({ prompt, batch, agent, log, modelUsed, primaryAgentAliasSetting, - fallbackAgent, fallbackModelOverride, fallbackModelUsed, fallbackAgentAliasSetting, fullName, branch + fallbackAgent, fallbackModelOverride, fallbackModelUsed, fallbackAgentAliasSetting, fullName, branch, + routingSession: options.routingSession, + fallbackRoutingSession, + onFallbackAttempt: () => { fallbackAttempted = true; }, }); agentUsed = summaries.agentUsed; modelLogged = summaries.modelLogged; fallbackUsed = summaries.fallbackUsed; fallbackPrimaryAgentAlias = summaries.primaryAgentAlias; fallbackAgentAlias = summaries.fallbackAgentAlias; + routingMetadata = summaries.routingMetadata; await saveBatchSummaries({ fullName, batch, summaries: summaries.results, modelUsed: modelLogged, branch }); success = true; log.debug({ savedCount: summaries.results.length }, 'Saved batch summaries'); } catch (error) { errorMessage = (error as Error).message; stopProcessing = error instanceof SummarizationCooldownRecordedError; + if (fallbackAttempted && fallbackAgent) { + agentUsed = fallbackAgent; + routingMetadata = fallbackRoutingSession?.routingMetadata; + modelLogged = fallbackModelUsed ?? fallbackModelOverride ?? fallbackAgent.config.defaultModel ?? 'unknown'; + } else { + routingMetadata = options.routingSession?.routingMetadata; + } + const physicalModel = routingMetadata?.physicalModel; + if (typeof physicalModel === 'string') modelLogged = physicalModel; log.error({ error: errorMessage, fileCount: batch.length }, 'Failed to process batch'); } const durationMs = Date.now() - startTime; await logFileBatchCall({ log, fullName, batch, modelLogged, agentUsed, estimatedInputTokens, - estimatedOutputTokens, durationMs, success, errorMessage + estimatedOutputTokens, durationMs, success, errorMessage, routingMetadata }); return { success, @@ -147,22 +141,17 @@ export async function processSingleBatch(options: ProcessSingleBatchOptions): Pr }; } -async function analyzeBatchWithFallback(options: ProcessSingleBatchOptions & { prompt: string }): Promise<{ - results: SummaryResult[]; - agentUsed: Agent; - modelLogged: string; - fallbackUsed: boolean; - primaryAgentAlias?: string; - fallbackAgentAlias?: string; -}> { +async function analyzeBatchWithFallback( + options: BatchAnalysisOptions +): Promise { const { - prompt, batch, agent, log, modelUsed, primaryAgentAliasSetting, - fallbackAgent, fallbackModelOverride, fallbackModelUsed, fallbackAgentAliasSetting, fullName, branch + prompt, batch, agent, log, modelUsed, primaryAgentAliasSetting, fullName, branch } = options; try { const results = await analyzeBatchWithAgent({ - prompt, batch, agent, model: modelUsed, context: `batch_summarization:${fullName}`, fullName + prompt, batch, agent, model: modelUsed, context: `batch_summarization:${fullName}`, fullName, + routingSession: options.routingSession, }); // Clearing quota-failure bookkeeping is best-effort: a transient runtime-state // read/write error here must not discard a batch the LLM summarized successfully. @@ -170,58 +159,97 @@ async function analyzeBatchWithFallback(options: ProcessSingleBatchOptions & { p { primaryAgentAlias: primaryAgentAliasSetting || agent.config.alias, repository: fullName, branch }, log ); - return { results, agentUsed: agent, modelLogged: modelUsed, fallbackUsed: false }; + const routingMetadata = options.routingSession?.routingMetadata; + const physicalModel = routingMetadata?.physicalModel; + return { + results, + agentUsed: agent, + modelLogged: typeof physicalModel === 'string' ? physicalModel : modelUsed, + routingMetadata, + fallbackUsed: false, + }; } catch (primaryError) { - const primaryAgentAlias = primaryAgentAliasSetting || agent.config.alias; - // Only quota/usage-limit exhaustion and invalid model output trigger the - // fallback model. Other failures (provider outages, agent bugs, malformed - // prompts) must surface as-is instead of silently switching models. - if (!isQuotaExhaustionError(primaryError)) { - if (isSummarizationInvalidResponseError(primaryError)) { - if (fallbackAgent && fallbackAgentAliasSetting) { - return analyzeBatchWithInvalidResponseFallback(primaryError, primaryAgentAlias, options); - } - await recordSummarizationCooldown({ - repository: fullName, - branch, - primaryAgentAlias, - reason: 'Primary summarization model returned unusable output after retries and no fallback model is configured.' - }); - throw new SummarizationCooldownRecordedError(primaryError); - } - throw primaryError; - } + return analyzeBatchAfterPrimaryFailure( + primaryError, primaryAgentAliasSetting || agent.config.alias, options + ); + } +} - if (!fallbackAgent || !fallbackAgentAliasSetting) { - await recordPrimarySummarizationQuotaFailure({ primaryAgentAlias }); - await recordSummarizationCooldown({ - repository: fullName, - branch, - primaryAgentAlias, - reason: 'Primary summarization model is quota-limited and no fallback model is configured.' - }); - throw new SummarizationCooldownRecordedError(primaryError); - } +async function analyzeNonQuotaPrimaryFailure( + primaryError: unknown, + primaryAgentAlias: string, + options: BatchAnalysisOptions +): Promise { + if (!isSummarizationInvalidResponseError(primaryError)) throw primaryError; + if (options.fallbackAgent && options.fallbackAgentAliasSetting) { + return analyzeBatchWithInvalidResponseFallback(primaryError, primaryAgentAlias, options); + } + await recordSummarizationCooldown({ + repository: options.fullName, + branch: options.branch, + primaryAgentAlias, + reason: 'Primary summarization model returned unusable output after retries and no fallback model is configured.' + }); + throw new SummarizationCooldownRecordedError(primaryError); +} + +async function analyzeBatchAfterPrimaryFailure( + primaryError: unknown, + primaryAgentAlias: string, + options: BatchAnalysisOptions +): Promise { + const { + prompt, batch, agent, log, fallbackAgent, fallbackModelOverride, + fallbackModelUsed, fallbackAgentAliasSetting, fullName, branch + } = options; + const syntheticRouteUnavailable = primaryError instanceof SyntheticPoolExhaustedError; + // Only quota/usage-limit exhaustion and invalid model output trigger the + // fallback model. An exhausted synthetic route is also eligible because it + // represents the configured primary pool being unavailable for this call. + if (!isQuotaExhaustionError(primaryError) && !syntheticRouteUnavailable) { + return analyzeNonQuotaPrimaryFailure(primaryError, primaryAgentAlias, options); + } + + if (syntheticRouteUnavailable && (!fallbackAgent || !fallbackAgentAliasSetting)) { + throw primaryError; + } + if (!fallbackAgent || !fallbackAgentAliasSetting) { + await recordPrimarySummarizationQuotaFailure({ primaryAgentAlias }); + await recordSummarizationCooldown({ + repository: fullName, + branch, + primaryAgentAlias, + reason: 'Primary summarization model is quota-limited and no fallback model is configured.' + }); + throw new SummarizationCooldownRecordedError(primaryError); + } + + if (!syntheticRouteUnavailable) { await recordPrimarySummarizationQuotaFailure({ primaryAgentAlias, fallbackAgentAlias: fallbackAgentAliasSetting }); + } - log.warn({ - error: (primaryError as Error).message, - primaryAgentAlias: agent.config.alias, - fallbackAgentAlias: fallbackAgent.config.alias, - fallbackModel: fallbackModelUsed ?? fallbackModelOverride - }, 'Primary summarization model quota-limited; retrying batch with fallback'); + log.warn({ + error: (primaryError as Error).message, + primaryAgentAlias: agent.config.alias, + fallbackAgentAlias: fallbackAgent.config.alias, + fallbackModel: fallbackModelUsed ?? fallbackModelOverride + }, primaryFallbackWarning(syntheticRouteUnavailable)); - try { - const results = await analyzeBatchWithAgent({ - prompt, - batch, - agent: fallbackAgent, - model: fallbackModelUsed ?? fallbackModelOverride, - context: `batch_summarization_fallback:${fullName}`, - fullName, - retryOptions: SUMMARIZATION_FALLBACK_RETRY - }); + const fallbackRoutingSession = options.fallbackRoutingSession; + options.onFallbackAttempt(); + try { + const results = await analyzeBatchWithAgent({ + prompt, + batch, + agent: fallbackAgent, + model: fallbackModelUsed ?? fallbackModelOverride, + context: `batch_summarization_fallback:${fullName}`, + fullName, + retryOptions: SUMMARIZATION_FALLBACK_RETRY, + routingSession: fallbackRoutingSession, + }); + if (!syntheticRouteUnavailable) { await clearSummarizationCooldown(fullName, branch, { primaryAgentAlias, fallbackAgentAlias: fallbackAgentAliasSetting, @@ -229,35 +257,41 @@ async function analyzeBatchWithFallback(options: ProcessSingleBatchOptions & { p }); // Promote only now that the fallback has proven it can summarize this batch. await promoteSummarizationFallbackIfNeeded({ primaryAgentAlias, fallbackAgentAlias: fallbackAgentAliasSetting }); - return { - results, - agentUsed: fallbackAgent, - modelLogged: fallbackModelUsed ?? fallbackModelOverride ?? fallbackAgent.config.defaultModel ?? 'unknown', - fallbackUsed: true, - primaryAgentAlias, - fallbackAgentAlias: fallbackAgentAliasSetting - }; - } catch (fallbackError) { - await recordCooldownAfterFallbackFailure({ - error: fallbackError, fullName, branch, agent, primaryAgentAliasSetting, fallbackAgentAliasSetting - }); - throw new SummarizationCooldownRecordedError(fallbackError); } + const routingMetadata = fallbackRoutingSession?.routingMetadata; + const physicalModel = routingMetadata?.physicalModel; + return { + results, + agentUsed: fallbackAgent, + modelLogged: typeof physicalModel === 'string' + ? physicalModel + : fallbackModelUsed ?? fallbackModelOverride ?? fallbackAgent.config.defaultModel ?? 'unknown', + routingMetadata, + fallbackUsed: true, + primaryAgentAlias, + fallbackAgentAlias: fallbackAgentAliasSetting + }; + } catch (fallbackError) { + if (syntheticRouteUnavailable) throw fallbackError; + await recordCooldownAfterFallbackFailure({ + error: fallbackError, fullName, branch, agent, + primaryAgentAliasSetting: options.primaryAgentAliasSetting, fallbackAgentAliasSetting + }); + throw new SummarizationCooldownRecordedError(fallbackError); } } +function primaryFallbackWarning(syntheticRouteUnavailable: boolean): string { + return syntheticRouteUnavailable + ? 'Primary synthetic summarization route unavailable; retrying batch with fallback' + : 'Primary summarization model quota-limited; retrying batch with fallback'; +} + async function analyzeBatchWithInvalidResponseFallback( primaryError: unknown, primaryAgentAlias: string, - options: ProcessSingleBatchOptions & { prompt: string } -): Promise<{ - results: SummaryResult[]; - agentUsed: Agent; - modelLogged: string; - fallbackUsed: boolean; - primaryAgentAlias?: string; - fallbackAgentAlias?: string; -}> { + options: BatchAnalysisOptions +): Promise { const { prompt, batch, fallbackAgent, fallbackModelOverride, fallbackModelUsed, fallbackAgentAliasSetting, fullName, log @@ -269,6 +303,8 @@ async function analyzeBatchWithInvalidResponseFallback( fallbackModel: fallbackModelUsed ?? fallbackModelOverride }, 'Primary summarization returned unusable output; retrying batch with fallback'); + const fallbackRoutingSession = options.fallbackRoutingSession; + options.onFallbackAttempt(); const results = await analyzeBatchWithAgent({ prompt, batch, @@ -276,23 +312,33 @@ async function analyzeBatchWithInvalidResponseFallback( model: fallbackModelUsed ?? fallbackModelOverride, context: `batch_summarization_fallback:${fullName}`, fullName, - retryOptions: SUMMARIZATION_FALLBACK_RETRY + retryOptions: SUMMARIZATION_FALLBACK_RETRY, + routingSession: fallbackRoutingSession, }); await recordPrimarySummarizationResponseFailure({ primaryAgentAlias, fallbackAgentAlias: fallbackAgentAliasSetting as string, reason: (primaryError as Error).message }); + const routingMetadata = fallbackRoutingSession?.routingMetadata; + const physicalModel = routingMetadata?.physicalModel; return { results, agentUsed: fallbackAgent as Agent, - modelLogged: fallbackModelUsed ?? fallbackModelOverride ?? fallbackAgent?.config.defaultModel ?? 'unknown', + modelLogged: typeof physicalModel === 'string' + ? physicalModel + : fallbackModelUsed ?? fallbackModelOverride ?? fallbackAgent?.config.defaultModel ?? 'unknown', + routingMetadata, fallbackUsed: true, primaryAgentAlias, fallbackAgentAlias: fallbackAgentAliasSetting }; } +function beginFallbackRoutingSession(agent: Agent | undefined, model: string | undefined): SyntheticRoutingSession | undefined { + return agent instanceof SyntheticAgent ? agent.beginRoutingSession(model) : undefined; +} + async function clearSummarizationPrimaryQuotaFailuresSafe( options: { primaryAgentAlias?: string; repository?: string; branch?: string }, log: Logger @@ -334,18 +380,22 @@ async function analyzeBatchWithAgent(options: { context: string; fullName: string; retryOptions?: RetryOptions; + routingSession?: SyntheticRoutingSession; }): Promise { - const { prompt, batch, agent, model, context, fullName, retryOptions = SUMMARIZATION_RETRY } = options; + const { prompt, batch, agent, model, context, fullName, retryOptions = SUMMARIZATION_RETRY, routingSession } = options; return withRetry( async () => { - const analysisResult = await agent.analyze(prompt, { + const analyzeOptions: AnalyzeOptions = { model, responseFormat: 'json', executionType: 'summarization', repository: fullName, metadata: { phase: 'batch_summarization', fileCount: batch.length }, suppressLlmLog: true - }); + }; + const analysisResult = routingSession + ? await routingSession.analyze(prompt, analyzeOptions) + : await agent.analyze(prompt, analyzeOptions); if (!analysisResult.success) { throw new Error(analysisResult.error || 'Summarization agent analysis failed'); } @@ -364,55 +414,3 @@ async function analyzeBatchWithAgent(options: { context ); } - -function buildBatchPrompt(batch: BatchFile[], customPrompt?: string): string { - const filesContent = batch.map(f => - `--- START ${f.path} ---\n${f.content}\n--- END ${f.path} ---` - ).join('\n\n'); - const instructions = customPrompt && customPrompt.trim().length > 0 - ? customPrompt - : DEFAULT_INSTRUCTIONS; - - return `${instructions} - -${JSON_FORMAT_RULES} - -FILES: -${filesContent}`; -} - -export function parseBatchResponse(response: string, expectedPaths?: string[]): SummaryResult[] { - try { - const jsonMatch = response.match(/\{[\s\S]*"summaries"[\s\S]*\}/); - if (!jsonMatch) { - logger.warn('No JSON found in batch response'); - return []; - } - - const parsed = JSON.parse(jsonMatch[0]) as { summaries: SummaryResult[] }; - if (!parsed.summaries || !Array.isArray(parsed.summaries)) { - logger.warn('Invalid summaries format in response'); - return []; - } - - return parsed.summaries - .filter(s => - typeof s.path === 'string' && - typeof s.summary === 'string' && - s.path.trim().length > 0 && - s.summary.trim().length > 0 - ) - .map(s => { - const expectedPath = expectedPaths - ? resolveExpectedSummaryPath(s.path, expectedPaths) - : s.path.trim(); - return expectedPath - ? { path: expectedPath, summary: s.summary.trim() } - : null; - }) - .filter((s): s is SummaryResult => s !== null); - } catch (error) { - logger.warn({ error: (error as Error).message }, 'Failed to parse batch response'); - return []; - } -} diff --git a/packages/core/src/services/relevance/summaryMinerBatchHelpers.ts b/packages/core/src/services/relevance/summaryMinerBatchHelpers.ts new file mode 100644 index 000000000..e5787cdb2 --- /dev/null +++ b/packages/core/src/services/relevance/summaryMinerBatchHelpers.ts @@ -0,0 +1,75 @@ +import logger from '../../utils/logger.js'; +import { resolveExpectedSummaryPath } from './summaryMinerDirectoryHelpers.js'; +import type { SummaryResult } from './summaryMinerBatchPersistence.js'; + +export interface BatchFile { + path: string; + content: string; + blobHash: string; +} + +export const DEFAULT_INSTRUCTIONS = `You are a code expert. Analyze the following source code files. +For each file, provide a summary (3-4 sentences) covering: +1. Primary purpose of the file +2. Key functions, classes, or exports it provides +3. What other parts of the system it interacts with or depends on`; + +const JSON_FORMAT_RULES = `Return ONLY valid JSON in this exact format: +{ + "summaries": [ + { "path": "relative/path/to/file", "summary": "This file handles... It provides... It interacts with..." } + ] +} + +Important: +- Include ALL files listed below in your response +- Each summary should be 3-4 sentences with specific details +- Mention key function/class names when relevant +- Focus on what the file does and how it connects to the system +- Return valid JSON only, no markdown or other formatting`; + +export function buildBatchPrompt(batch: BatchFile[], customPrompt?: string): string { + const filesContent = batch.map(file => + `--- START ${file.path} ---\n${file.content}\n--- END ${file.path} ---` + ).join('\n\n'); + const instructions = customPrompt && customPrompt.trim().length > 0 + ? customPrompt + : DEFAULT_INSTRUCTIONS; + + return `${instructions} + +${JSON_FORMAT_RULES} + +FILES: +${filesContent}`; +} + +export function parseBatchResponse(response: string, expectedPaths?: string[]): SummaryResult[] { + try { + const jsonMatch = response.match(/\{[\s\S]*"summaries"[\s\S]*\}/); + if (!jsonMatch) { + logger.warn('No JSON found in batch response'); + return []; + } + + const parsed = JSON.parse(jsonMatch[0]) as { summaries: SummaryResult[] }; + if (!Array.isArray(parsed.summaries)) { + logger.warn('Invalid summaries format in response'); + return []; + } + + return parsed.summaries + .filter(summary => typeof summary.path === 'string' && typeof summary.summary === 'string' + && summary.path.trim().length > 0 && summary.summary.trim().length > 0) + .map(summary => { + const expectedPath = expectedPaths + ? resolveExpectedSummaryPath(summary.path, expectedPaths) + : summary.path.trim(); + return expectedPath ? { path: expectedPath, summary: summary.summary.trim() } : null; + }) + .filter((summary): summary is SummaryResult => summary !== null); + } catch (error) { + logger.warn({ error: (error as Error).message }, 'Failed to parse batch response'); + return []; + } +} diff --git a/packages/core/src/services/relevance/summaryMinerBatchPersistence.ts b/packages/core/src/services/relevance/summaryMinerBatchPersistence.ts index 15d507e44..63e7ab584 100644 --- a/packages/core/src/services/relevance/summaryMinerBatchPersistence.ts +++ b/packages/core/src/services/relevance/summaryMinerBatchPersistence.ts @@ -56,17 +56,21 @@ export async function logFileBatchCall(options: { durationMs: number; success: boolean; errorMessage?: string; + routingMetadata?: Record; }): Promise { const { log, fullName, batch, modelLogged, agentUsed, estimatedInputTokens, - estimatedOutputTokens, durationMs, success, errorMessage + estimatedOutputTokens, durationMs, success, errorMessage, routingMetadata } = options; + const physicalAgentAlias = typeof routingMetadata?.physicalAgentAlias === 'string' + ? routingMetadata.physicalAgentAlias + : agentUsed.config.alias; await logSummarizationCall({ timestamp: new Date().toISOString(), callType: 'batch_summarization', model: modelLogged, - agentAlias: agentUsed.config.alias, + agentAlias: physicalAgentAlias, repository: fullName, estimatedInputTokens, estimatedOutputTokens, @@ -85,7 +89,11 @@ export async function logFileBatchCall(options: { tokenUsage: { input_tokens: estimatedInputTokens, output_tokens: estimatedOutputTokens }, error: errorMessage, repository: fullName, - agentAlias: agentUsed.config.alias, + agentAlias: physicalAgentAlias, + metadata: { + phase: 'batch_summarization', + ...(routingMetadata && { syntheticRouting: routingMetadata }), + }, workRef: { workType: 'repository', workRepository: fullName }, })); } diff --git a/packages/core/src/services/relevance/summaryMinerDirectories.ts b/packages/core/src/services/relevance/summaryMinerDirectories.ts index b8e90cecc..49d2bdef4 100644 --- a/packages/core/src/services/relevance/summaryMinerDirectories.ts +++ b/packages/core/src/services/relevance/summaryMinerDirectories.ts @@ -5,7 +5,9 @@ import type { Logger } from 'pino'; import { Agent } from '../../agents/types.js'; import { db } from '../../db/connection.js'; import { startDirectoryPhase, updateDirectoryProgress, publishProgress, isIndexingCancelled } from './indexingCancellation.js'; -import { MODEL_LIMITS } from '../../config/modelLimits.js'; +import { getModelHardLimit } from '../../config/modelLimits.js'; +import { SyntheticAgent } from '../../agents/SyntheticAgent.js'; +import { AgentRegistry } from '../../agents/AgentRegistry.js'; import type { IndexingProgress } from './indexingCancellation.js'; import type { SummarizationAgentConfig } from './summaryMinerHelpers.js'; import { @@ -14,6 +16,7 @@ import { } from './summaryMinerDirectoryHelpers.js'; import { processDirectoryBatch } from './summaryMinerDirectoryBatch.js'; import { getSummarizationBatchLimitOverride } from './summaryMinerBatchLimits.js'; +import type { SyntheticRoutingSession } from '../syntheticRoutingService.js'; const CHARS_PER_TOKEN_ESTIMATE = 3; const BATCH_TOKEN_RATIO = 0.5; @@ -66,6 +69,7 @@ interface ProcessDepthOptions { getCurrentConfig: () => Promise; initialConfig: SummarizationAgentConfig; log: Logger; + takeRoutingSession: (agent: Agent, model: string) => Promise; } /** Aggregates file summaries into directory summaries (bottom-up), batching multiple directories per API call. */ @@ -93,7 +97,7 @@ export async function aggregateDirectories(options: AggregateDirectoriesOptions) await startDirectoryPhase(fullName, branch, totalDirs); const dirSummaryCache = new Map(); - const { modelId, maxBatchTokens, maxDirsPerBatch } = computeDirectoryBatchBudget(agent, modelOverride, log); + const { modelId, maxBatchTokens, maxDirsPerBatch, routingSession: firstRoutingSession } = computeDirectoryBatchBudget(agent, modelOverride, log); const state: DirectoryAggregationState = { totalBatches: 0, failedBatches: 0, dirsProcessed: 0, fallbackUsed: false, stopProcessing: false }; const initialConfig: SummarizationAgentConfig = { @@ -107,6 +111,17 @@ export async function aggregateDirectories(options: AggregateDirectoriesOptions) fallbackAgentAliasSetting }; const getCurrentConfig = resolveSummarizationConfig ?? (async () => initialConfig); + let availableRoutingSession = firstRoutingSession; + const takeRoutingSession = async (currentAgent: Agent, currentModel: string): Promise => { + if (!(currentAgent instanceof SyntheticAgent)) return undefined; + const route = availableRoutingSession + && availableRoutingSession.requestedAgentAlias === currentAgent.config.alias + && availableRoutingSession.requestedModel === currentModel + ? availableRoutingSession + : AgentRegistry.getInstance().beginRoutingSession({ requestedAgentAlias: currentAgent.config.alias, requestedModel: currentModel }); + availableRoutingSession = route.fork(); + return route; + }; for (const depth of depths) { const depthResult = await processDirectoryDepth({ @@ -119,7 +134,8 @@ export async function aggregateDirectories(options: AggregateDirectoriesOptions) maxDirsPerBatch, getCurrentConfig, initialConfig, - log + log, + takeRoutingSession, }); mergeDirectoryAggregationResult(state, depthResult); if (state.stopProcessing) break; @@ -168,10 +184,35 @@ function computeDirectoryBatchBudget( agent: Agent, modelOverride: string | undefined, log: Logger -): { modelId: string; maxBatchTokens: number; maxDirsPerBatch: number } { +): { modelId: string; maxBatchTokens: number; maxDirsPerBatch: number; routingSession?: SyntheticRoutingSession } { const modelId = modelOverride || agent.config.defaultModel || 'default'; - const maxTokens = MODEL_LIMITS[modelId] || MODEL_LIMITS['default']; - const modelBatchLimitOverride = getSummarizationBatchLimitOverride(modelId); + let budgetModelId = modelId; + let routingSession: SyntheticRoutingSession | undefined; + if (agent instanceof SyntheticAgent) { + const registry = AgentRegistry.getInstance(); + routingSession = registry.beginRoutingSession({ + requestedAgentAlias: agent.config.alias, + requestedModel: modelId, + }); + const model = agent.syntheticConfig.models.find(item => item.id === modelId); + const enabledMembers = model?.members.filter(member => { + const directAgent = registry.getAgentByAlias(member.directAgentAlias); + return member.enabled + && directAgent?.config.enabled + && directAgent.config.supportedModels.includes(member.model); + }) ?? []; + if (enabledMembers.length > 0) { + const conservativeMember = enabledMembers.reduce((smallest, member) => + getModelHardLimit(`${member.directAgentAlias}:${member.model}`) + < getModelHardLimit(`${smallest.directAgentAlias}:${smallest.model}`) + ? member + : smallest); + budgetModelId = `${conservativeMember.directAgentAlias}:${conservativeMember.model}`; + } + } + const maxTokens = getModelHardLimit(budgetModelId); + const budgetModelName = budgetModelId.includes(':') ? budgetModelId.slice(budgetModelId.indexOf(':') + 1) : budgetModelId; + const modelBatchLimitOverride = getSummarizationBatchLimitOverride(budgetModelName); const defaultMaxBatchTokens = modelBatchLimitOverride?.maxBatchTokens ?? DEFAULT_MAX_DIRECTORY_BATCH_TOKENS; const maxBatchTokensCap = parseInt(process.env.SUMMARIZATION_MAX_DIRECTORY_BATCH_TOKENS || String(defaultMaxBatchTokens), 10); const maxBatchTokens = Math.min(Math.floor(maxTokens * BATCH_TOKEN_RATIO), maxBatchTokensCap); @@ -184,10 +225,10 @@ function computeDirectoryBatchBudget( maxBatchTokens, maxBatchTokensCap, maxDirsPerBatch, - model: modelId, + model: budgetModelId, modelBatchLimitOverride: modelBatchLimitOverride ? { maxBatchTokens: modelBatchLimitOverride.maxBatchTokens, maxItemsPerBatch: modelBatchLimitOverride.maxItemsPerBatch } : null }, 'Calculated directory batch budget'); - return { modelId, maxBatchTokens, maxDirsPerBatch }; + return { modelId, maxBatchTokens, maxDirsPerBatch, routingSession }; } function mergeDirectoryAggregationResult(state: DirectoryAggregationState, result: DirectoryAggregationState): void { @@ -206,17 +247,20 @@ async function processDirectoryAggregationBatch(batch: DirectoryInfo[], options: const { getCurrentConfig, initialConfig, log, fullName, branch, dirSummaryCache } = options; const currentConfig = await getCurrentConfig(); logDirectoryBatchAgentIfChanged(log, initialConfig, currentConfig); + const currentModel = currentConfig.effectiveModel || currentConfig.modelOverride || currentConfig.agent.config.defaultModel || 'default'; + const routingSession = await options.takeRoutingSession(currentConfig.agent, currentModel); const results = await processDirectoryBatch({ directories: batch, agent: currentConfig.agent, log, modelOverride: currentConfig.modelOverride, - modelUsed: currentConfig.effectiveModel || currentConfig.modelOverride || currentConfig.agent.config.defaultModel, + modelUsed: currentModel, primaryAgentAliasSetting: currentConfig.agentAliasSetting || currentConfig.agent.config.alias, fallbackAgent: currentConfig.fallbackAgent, fallbackModelOverride: currentConfig.fallbackModelOverride, fallbackModelUsed: currentConfig.fallbackEffectiveModel || currentConfig.fallbackModelOverride || currentConfig.fallbackAgent?.config.defaultModel, fallbackAgentAliasSetting: currentConfig.fallbackAgentAliasSetting, fullName, - branch + branch, + routingSession, }); const failedBatches = results.some(r => r.summary) ? 0 : 1; let dirsProcessed = 0; diff --git a/packages/core/src/services/relevance/summaryMinerDirectoryBatch.ts b/packages/core/src/services/relevance/summaryMinerDirectoryBatch.ts index aeb85eb65..f5363979c 100644 --- a/packages/core/src/services/relevance/summaryMinerDirectoryBatch.ts +++ b/packages/core/src/services/relevance/summaryMinerDirectoryBatch.ts @@ -1,5 +1,5 @@ import type { Logger } from 'pino'; -import { Agent } from '../../agents/types.js'; +import { Agent, type AnalyzeOptions } from '../../agents/types.js'; import { logSummarizationCall } from './summaryMinerMetrics.js'; import { persistLlmLog, createLlmLogFromAnalysis } from '../../utils/llmLogger.js'; import { isQuotaExhaustionError, withRetry, type RetryOptions } from '../../utils/retryHandler.js'; @@ -12,6 +12,8 @@ import { recordPrimarySummarizationResponseFailure, recordSummarizationCooldown } from '../../config/configManager.js'; +import { SyntheticPoolExhaustedError, type SyntheticRoutingSession } from '../syntheticRoutingService.js'; +import { SyntheticAgent } from '../../agents/SyntheticAgent.js'; import { type DirectoryInfo, type DirectoryResult, buildBatchDirectoryPrompt, parseBatchDirectoryResponse @@ -65,6 +67,8 @@ interface ProcessDirectoryBatchOptions { fallbackAgentAliasSetting?: string; fullName: string; branch: string; + routingSession?: SyntheticRoutingSession; + fallbackRoutingSession?: SyntheticRoutingSession; } class SummarizationCooldownRecordedError extends Error { @@ -88,12 +92,22 @@ export async function processDirectoryBatch(options: ProcessDirectoryBatchOption const estimatedInputTokens = Math.ceil(prompt.length / CHARS_PER_TOKEN_ESTIMATE); const estimatedOutputTokens = directories.length * 150; const state = createDirectoryBatchState(options); + const fallbackRoutingSession = beginFallbackRoutingSession( + options.fallbackAgent, + options.fallbackModelUsed ?? options.fallbackModelOverride + ); try { - await analyzeDirectoryBatchWithFallback({ ...options, prompt, state }); + await analyzeDirectoryBatchWithFallback({ ...options, prompt, state, fallbackRoutingSession }); state.success = state.results.some(r => r.summary !== null); options.log.debug({ batchSize: directories.length, successCount: state.results.filter(r => r.summary).length }, 'Processed directory batch'); } catch (error) { + if (fallbackRoutingSession?.routingMetadata && options.fallbackAgent) { + state.routingMetadata = fallbackRoutingSession.routingMetadata; + state.agentUsed = options.fallbackAgent; + const physicalModel = state.routingMetadata.physicalModel; + if (typeof physicalModel === 'string') state.modelLogged = physicalModel; + } state.errorMessage = (error as Error).message; state.stopProcessing = error instanceof SummarizationCooldownRecordedError; options.log.warn({ error: state.errorMessage, batchSize: directories.length }, 'Failed to process directory batch'); @@ -113,6 +127,7 @@ export async function processDirectoryBatch(options: ProcessDirectoryBatchOption interface DirectoryBatchState { agentUsed: Agent; modelLogged: string; + routingMetadata?: Record; success: boolean; errorMessage?: string; results: DirectoryResult[]; @@ -141,8 +156,12 @@ async function analyzeDirectoryBatchWithFallback(options: ProcessDirectoryBatchO const { prompt, directories, agent, modelOverride, modelUsed, fullName, branch, primaryAgentAliasSetting, log, state } = options; try { state.results = await analyzeDirectoryBatchWithAgent({ - prompt, directories, agent, model: modelUsed ?? modelOverride, context: `directory_aggregation:${fullName}`, fullName + prompt, directories, agent, model: modelUsed ?? modelOverride, context: `directory_aggregation:${fullName}`, fullName, + routingSession: options.routingSession, }); + state.routingMetadata = options.routingSession?.routingMetadata; + const physicalModel = state.routingMetadata?.physicalModel; + if (typeof physicalModel === 'string') state.modelLogged = physicalModel; // Best-effort bookkeeping: a transient runtime-state error here must not // discard a directory batch the LLM aggregated successfully. await clearSummarizationPrimaryQuotaFailuresSafe( @@ -150,6 +169,9 @@ async function analyzeDirectoryBatchWithFallback(options: ProcessDirectoryBatchO log ); } catch (primaryError) { + state.routingMetadata = options.routingSession?.routingMetadata; + const physicalModel = state.routingMetadata?.physicalModel; + if (typeof physicalModel === 'string') state.modelLogged = physicalModel; await handlePrimaryDirectoryFailure(primaryError, options); } } @@ -171,13 +193,14 @@ async function handlePrimaryDirectoryFailure( ): Promise { const { agent, primaryAgentAliasSetting, fallbackAgent, fallbackAgentAliasSetting, fullName, branch } = options; const primaryAgentAlias = primaryAgentAliasSetting || agent.config.alias; + const syntheticRouteUnavailable = primaryError instanceof SyntheticPoolExhaustedError; // Only quota/usage-limit exhaustion and invalid model output switch to the - // fallback model. Other failures (transient outages, agent bugs, malformed - // prompts) propagate. - if (!isQuotaExhaustionError(primaryError)) { + // fallback model. An exhausted synthetic route is also eligible because it + // represents the configured primary pool being unavailable for this call. + if (!isQuotaExhaustionError(primaryError) && !syntheticRouteUnavailable) { if (isSummarizationInvalidResponseError(primaryError)) { if (fallbackAgent && fallbackAgentAliasSetting) { - await analyzeDirectoryBatchWithFallbackAgent(primaryError, primaryAgentAlias, options, false); + await analyzeDirectoryBatchWithFallbackAgent(primaryError, primaryAgentAlias, options, 'invalid-response'); return; } await recordSummarizationCooldown({ @@ -191,6 +214,12 @@ async function handlePrimaryDirectoryFailure( throw primaryError; } + if (syntheticRouteUnavailable) { + if (!fallbackAgent || !fallbackAgentAliasSetting) throw primaryError; + await analyzeDirectoryBatchWithFallbackAgent(primaryError, primaryAgentAlias, options, 'synthetic-route'); + return; + } + if (!fallbackAgent || !fallbackAgentAliasSetting) { await recordPrimarySummarizationQuotaFailure({ primaryAgentAlias }); await recordSummarizationCooldown({ @@ -202,17 +231,17 @@ async function handlePrimaryDirectoryFailure( throw new SummarizationCooldownRecordedError(primaryError); } - await analyzeDirectoryBatchWithFallbackAgent(primaryError, primaryAgentAlias, options, true); + await analyzeDirectoryBatchWithFallbackAgent(primaryError, primaryAgentAlias, options, 'quota'); } async function analyzeDirectoryBatchWithFallbackAgent( primaryError: unknown, primaryAgentAlias: string, options: ProcessDirectoryBatchOptions & { prompt: string; state: DirectoryBatchState }, - primaryWasQuotaLimited: boolean + failureKind: 'quota' | 'invalid-response' | 'synthetic-route' ): Promise { const { prompt, directories, fallbackAgent, fallbackModelOverride, fallbackModelUsed, fallbackAgentAliasSetting, fullName, branch, log, state } = options; - if (primaryWasQuotaLimited) { + if (failureKind === 'quota') { await recordPrimarySummarizationQuotaFailure({ primaryAgentAlias, fallbackAgentAlias: fallbackAgentAliasSetting }); } log.warn({ @@ -220,10 +249,10 @@ async function analyzeDirectoryBatchWithFallbackAgent( primaryAgentAlias, fallbackAgentAlias: fallbackAgent?.config.alias, fallbackModel: fallbackModelUsed ?? fallbackModelOverride - }, primaryWasQuotaLimited - ? 'Primary directory summarization model quota-limited; retrying batch with fallback' - : 'Primary directory summarization returned unusable output; retrying batch with fallback'); + }, directoryFallbackWarning(failureKind)); + const fallbackRoutingSession = options.fallbackRoutingSession; + markDirectoryFallbackAttempt(state, fallbackAgent as Agent, fallbackModelUsed ?? fallbackModelOverride); try { state.results = await analyzeDirectoryBatchWithAgent({ prompt, @@ -232,14 +261,19 @@ async function analyzeDirectoryBatchWithFallbackAgent( model: fallbackModelUsed ?? fallbackModelOverride, context: `directory_aggregation_fallback:${fullName}`, fullName, - retryOptions: SUMMARIZATION_FALLBACK_RETRY + retryOptions: SUMMARIZATION_FALLBACK_RETRY, + routingSession: fallbackRoutingSession, }); state.fallbackUsed = true; state.fallbackPrimaryAgentAlias = primaryAgentAlias; state.fallbackAgentAlias = fallbackAgentAliasSetting; state.agentUsed = fallbackAgent as Agent; - state.modelLogged = fallbackModelUsed ?? fallbackModelOverride ?? fallbackAgent?.config.defaultModel ?? 'unknown'; - if (primaryWasQuotaLimited) { + state.routingMetadata = fallbackRoutingSession?.routingMetadata; + const physicalModel = state.routingMetadata?.physicalModel; + state.modelLogged = typeof physicalModel === 'string' + ? physicalModel + : fallbackModelUsed ?? fallbackModelOverride ?? fallbackAgent?.config.defaultModel ?? 'unknown'; + if (failureKind === 'quota') { await clearSummarizationCooldown(fullName, branch, { primaryAgentAlias, fallbackAgentAlias: fallbackAgentAliasSetting, @@ -251,7 +285,7 @@ async function analyzeDirectoryBatchWithFallbackAgent( if (fallbackAgentAliasSetting) { await promoteSummarizationFallbackIfNeeded({ primaryAgentAlias, fallbackAgentAlias: fallbackAgentAliasSetting }); } - } else if (isSummarizationInvalidResponseError(primaryError)) { + } else if (shouldRecordResponseFailure(failureKind, primaryError)) { await recordPrimarySummarizationResponseFailure({ primaryAgentAlias, fallbackAgentAlias: fallbackAgentAliasSetting as string, @@ -259,7 +293,7 @@ async function analyzeDirectoryBatchWithFallbackAgent( }); } } catch (fallbackError) { - if (!primaryWasQuotaLimited) throw fallbackError; + if (failureKind !== 'quota') throw fallbackError; await recordSummarizationCooldown({ repository: fullName, branch, @@ -273,6 +307,32 @@ async function analyzeDirectoryBatchWithFallbackAgent( } } +function directoryFallbackWarning(failureKind: 'quota' | 'invalid-response' | 'synthetic-route'): string { + return failureKind === 'quota' + ? 'Primary directory summarization model quota-limited; retrying batch with fallback' + : failureKind === 'synthetic-route' + ? 'Primary synthetic directory summarization route unavailable; retrying batch with fallback' + : 'Primary directory summarization returned unusable output; retrying batch with fallback'; +} + +function markDirectoryFallbackAttempt( + state: DirectoryBatchState, + fallbackAgent: Agent, + fallbackModel: string | undefined +): void { + state.agentUsed = fallbackAgent; + state.modelLogged = fallbackModel ?? fallbackAgent.config.defaultModel ?? 'unknown'; + state.routingMetadata = undefined; +} + +function shouldRecordResponseFailure(failureKind: string, error: unknown): boolean { + return failureKind === 'invalid-response' && isSummarizationInvalidResponseError(error); +} + +function beginFallbackRoutingSession(agent: Agent | undefined, model: string | undefined): SyntheticRoutingSession | undefined { + return agent instanceof SyntheticAgent ? agent.beginRoutingSession(model) : undefined; +} + async function logDirectoryBatchCall(options: ProcessDirectoryBatchOptions & { state: DirectoryBatchState; estimatedInputTokens: number; @@ -280,9 +340,12 @@ async function logDirectoryBatchCall(options: ProcessDirectoryBatchOptions & { durationMs: number; }): Promise { const { directories, fullName, log, state, estimatedInputTokens, estimatedOutputTokens, durationMs } = options; + const physicalAgentAlias = typeof state.routingMetadata?.physicalAgentAlias === 'string' + ? state.routingMetadata.physicalAgentAlias + : state.agentUsed.config.alias; await logSummarizationCall({ timestamp: new Date().toISOString(), callType: 'directory_aggregation', model: state.modelLogged, - agentAlias: state.agentUsed.config.alias, repository: fullName, estimatedInputTokens, estimatedOutputTokens, + agentAlias: physicalAgentAlias, repository: fullName, estimatedInputTokens, estimatedOutputTokens, estimatedTotalTokens: estimatedInputTokens + estimatedOutputTokens, fileCount: directories.length, success: state.success, durationMs, error: state.errorMessage }, log); @@ -290,8 +353,12 @@ async function logDirectoryBatchCall(options: ProcessDirectoryBatchOptions & { await persistLlmLog(createLlmLogFromAnalysis({ executionType: 'summarization', modelUsed: state.modelLogged, executionTimeMs: durationMs, success: state.success, tokenUsage: { input_tokens: estimatedInputTokens, output_tokens: estimatedOutputTokens }, - error: state.errorMessage, repository: fullName, agentAlias: state.agentUsed.config.alias, - metadata: { directoryCount: directories.length, phase: 'directory_aggregation' }, + error: state.errorMessage, repository: fullName, agentAlias: physicalAgentAlias, + metadata: { + directoryCount: directories.length, + phase: 'directory_aggregation', + ...(state.routingMetadata && { syntheticRouting: state.routingMetadata }), + }, workRef: { workType: 'repository', workRepository: fullName }, })); } @@ -316,18 +383,22 @@ async function analyzeDirectoryBatchWithAgent(options: { context: string; fullName: string; retryOptions?: RetryOptions; + routingSession?: SyntheticRoutingSession; }): Promise { - const { prompt, directories, agent, model, context, fullName, retryOptions = SUMMARIZATION_RETRY } = options; + const { prompt, directories, agent, model, context, fullName, retryOptions = SUMMARIZATION_RETRY, routingSession } = options; return withRetry( async () => { - const analysisResult = await agent.analyze(prompt, { + const analyzeOptions: AnalyzeOptions = { model, responseFormat: 'json', executionType: 'summarization', repository: fullName, metadata: { phase: 'directory_aggregation', directoryCount: directories.length }, suppressLlmLog: true - }); + }; + const analysisResult = routingSession + ? await routingSession.analyze(prompt, analyzeOptions) + : await agent.analyze(prompt, analyzeOptions); if (!analysisResult.success) { throw new Error(analysisResult.error || 'Directory summarization agent analysis failed'); } diff --git a/packages/core/src/services/relevance/summaryMinerHelpers.ts b/packages/core/src/services/relevance/summaryMinerHelpers.ts index 23b8b61c4..ebb51195f 100644 --- a/packages/core/src/services/relevance/summaryMinerHelpers.ts +++ b/packages/core/src/services/relevance/summaryMinerHelpers.ts @@ -2,7 +2,9 @@ import fs from 'fs'; import path from 'path'; import type { Logger } from 'pino'; import { Agent } from '../../agents/types.js'; -import { MODEL_LIMITS } from '../../config/modelLimits.js'; +import { getModelHardLimit } from '../../config/modelLimits.js'; +import { SyntheticAgent } from '../../agents/SyntheticAgent.js'; +import { AgentRegistry } from '../../agents/AgentRegistry.js'; import type { GitFileInfo } from './summaryFileFilter.js'; import { getSummarizationMetricsSummary, getSummarizationCallHistory } from './summaryMinerMetrics.js'; import type { SummarizationCallMetrics, SummarizationMetricsSummary } from './summaryMinerMetrics.js'; @@ -11,6 +13,7 @@ import { isIndexingCancelled, IndexingCancelledError, updateIndexingProgress, pu import { isProcessableFile } from './summaryFileFilter.js'; import { processSingleBatch, type BatchFile } from './summaryMinerBatch.js'; import { getSummarizationBatchLimitOverride } from './summaryMinerBatchLimits.js'; +import type { SyntheticRoutingSession } from '../syntheticRoutingService.js'; // Re-export metrics types and functions for backwards compatibility export { getSummarizationMetricsSummary, getSummarizationCallHistory }; @@ -91,8 +94,33 @@ export async function processBatches(options: ProcessBatchesOptions): Promise item.id === modelId); + const enabledMembers = model?.members.filter(member => { + const directAgent = registry.getAgentByAlias(member.directAgentAlias); + return member.enabled + && directAgent?.config.enabled + && directAgent.config.supportedModels.includes(member.model); + }) ?? []; + if (enabledMembers.length > 0) { + const conservativeMember = enabledMembers.reduce((smallest, member) => + getModelHardLimit(`${member.directAgentAlias}:${member.model}`) + < getModelHardLimit(`${smallest.directAgentAlias}:${smallest.model}`) + ? member + : smallest); + budgetModelId = `${conservativeMember.directAgentAlias}:${conservativeMember.model}`; + } + } + const maxTokens = getModelHardLimit(budgetModelId); + const budgetModelName = budgetModelId.includes(':') ? budgetModelId.slice(budgetModelId.indexOf(':') + 1) : budgetModelId; + const modelBatchLimitOverride = getSummarizationBatchLimitOverride(budgetModelName); const defaultMaxBatchTokens = modelBatchLimitOverride?.maxBatchTokens ?? DEFAULT_MAX_BATCH_TOKENS; const defaultMaxBatchFiles = modelBatchLimitOverride?.maxItemsPerBatch ?? DEFAULT_MAX_BATCH_FILES; const maxBatchTokensCap = parseInt(process.env.SUMMARIZATION_MAX_BATCH_TOKENS || String(defaultMaxBatchTokens), 10); @@ -105,7 +133,7 @@ export async function processBatches(options: ProcessBatchesOptions): Promise initialConfig); + let availableRoutingSession = firstRoutingSession; + const takeRoutingSession = async (currentAgent: Agent, currentModel: string): Promise => { + if (!(currentAgent instanceof SyntheticAgent)) return undefined; + const route = availableRoutingSession + && availableRoutingSession.requestedAgentAlias === currentAgent.config.alias + && availableRoutingSession.requestedModel === currentModel + ? availableRoutingSession + : AgentRegistry.getInstance().beginRoutingSession({ requestedAgentAlias: currentAgent.config.alias, requestedModel: currentModel }); + availableRoutingSession = route.fork(); + return route; + }; for (const file of files) { // Check for cancellation before processing each file @@ -167,6 +206,7 @@ export async function processBatches(options: ProcessBatchesOptions): Promise { const correlatedLogger = correlationId ? logger.withCorrelation(correlationId) : logger; let keywords = extractKeywords(prompt); if (useLLMKeywords && agent) { try { - const llmKeywords = await extractKeywordsWithLLM(prompt, { agent, correlationId }); + const llmKeywords = await extractKeywordsWithLLM(prompt, { agent, correlationId, routingSession }); keywords = mergeKeywords(keywords, llmKeywords); correlatedLogger.info({ basicCount: extractKeywords(prompt).length, @@ -283,9 +291,9 @@ async function performSummaryScoring( prompt: string, agent: Agent, finalScores: Record, - options: { correlationId?: string; modelId?: string; repoName?: string; branch?: string } + options: { correlationId?: string; modelId?: string; repoName?: string; branch?: string; routingSession?: SyntheticRoutingSession } ): Promise { - const { correlationId, modelId, repoName, branch } = options; + const { correlationId, modelId, repoName, branch, routingSession } = options; const correlatedLogger = correlationId ? logger.withCorrelation(correlationId) : logger; try { @@ -303,7 +311,8 @@ async function performSummaryScoring( correlationId, modelId, repoName, - branch + branch, + routingSession, }; const summaryScores = await scoreSemanticRelevance(prompt, summaryOptions); @@ -338,7 +347,8 @@ export async function findRelevantFiles( repoName, branch, useLLMKeywords = false, - keywordTimeoutMs = TIMEOUT_MS + keywordTimeoutMs = TIMEOUT_MS, + routingSession, } = options; const correlatedLogger = correlationId ? logger.withCorrelation(correlationId) : logger; @@ -352,7 +362,12 @@ export async function findRelevantFiles( }, 'Starting relevance analysis'); // Extract keywords - optionally enhanced with LLM - const keywords = await extractKeywordsForRelevance(prompt, agent, useLLMKeywords, correlationId); + const keywords = await extractKeywordsForRelevance(prompt, { + agent, + useLLMKeywords, + correlationId, + routingSession: routingSession?.fork(), + }); correlatedLogger.debug({ keywords }, 'Extracted keywords'); @@ -380,7 +395,7 @@ export async function findRelevantFiles( // --- Phase 3: Summary-based Semantic Scoring --- if (useSummaryScoring && agent) { usedSummaryScoring = await performSummaryScoring(prompt, agent, finalScores, { - correlationId, modelId, repoName, branch + correlationId, modelId, repoName, branch, routingSession }); } diff --git a/packages/core/src/services/syntheticRoutingService.ts b/packages/core/src/services/syntheticRoutingService.ts new file mode 100644 index 000000000..f41a203a7 --- /dev/null +++ b/packages/core/src/services/syntheticRoutingService.ts @@ -0,0 +1,449 @@ +import { randomUUID } from 'node:crypto'; +import type { Knex } from 'knex'; +import type { SyntheticAgentConfig, SyntheticModelConfig, SyntheticModelMember } from '@propr/shared'; +import { db } from '../db/connection.js'; +import { getModelHardLimit } from '../config/modelLimits.js'; +import { loadSyntheticAgents } from '../config/configManager.js'; +import type { Agent, AgentExecutionResult, AgentTaskOptions, AnalysisResult, AnalyzeOptions } from '../agents/types.js'; +import logger from '../utils/logger.js'; +import { estimateTokens } from '../utils/tokenCalculation.js'; +import { SyntheticPoolExhaustedError, isNonRetryableSyntheticFailure } from './syntheticRoutingTypes.js'; +import type { BeginSyntheticRoutingOptions, SyntheticMemberDiagnostic, SyntheticPhysicalSelection, SyntheticRoutingServiceOptions, SyntheticUsageSnapshotProvider } from './syntheticRoutingTypes.js'; +import { AliasSpecificAgentTankSnapshotProvider } from './syntheticUsageSnapshotProvider.js'; + +export * from './syntheticRoutingTypes.js'; +export { AliasSpecificAgentTankSnapshotProvider } from './syntheticUsageSnapshotProvider.js'; + +const DEFAULT_OUTPUT_RESERVE_TOKENS = 16_000; + +/** + * Estimate every caller-provided field that can become part of an implementation + * model's input. Keep this calculation call-scoped so an early selection and all + * subsequent failover attempts use the same context constraint. + */ +export function estimateTaskRequiredTokens(options: AgentTaskOptions): number { + const inputParts = [options.prompt]; + if (options.systemPrompt) inputParts.push(options.systemPrompt); + if (options.retryReason) inputParts.push(options.retryReason); + if (options.tools) inputParts.push(options.tools); + + // A missing custom prompt makes the physical adapters generate one from the + // task metadata. Account for the token-bearing values used by that path. + if (!options.prompt) { + inputParts.push( + options.issueRef.repoOwner, + options.issueRef.repoName, + String(options.issueRef.number), + options.branchName || '', + options.model || '', + options.issueDetails ? JSON.stringify(options.issueDetails) || '' : '', + ); + } + + return estimateTokens(inputParts.join('\n')) + DEFAULT_OUTPUT_RESERVE_TOKENS; +} + +interface EligibleMember { + member: SyntheticModelMember; + agent: Agent; + headroom: number; +} + +function resultFailure(result: AnalysisResult | AgentExecutionResult): Error { + const error = new Error(result.error || 'Physical agent execution failed'); + const resultError = result as typeof result & { errorName?: string; errorCode?: string }; + if (resultError.errorName) error.name = resultError.errorName; + if (resultError.errorCode) (error as Error & { code?: string }).code = resultError.errorCode; + return error; +} + +export class SyntheticRoutingSession { + private current?: SyntheticPhysicalSelection; + private lastFailedSelection?: SyntheticPhysicalSelection; + private readonly attemptedMemberIds = new Set(); + private readonly attemptFailures = new Map(); + private executionAttemptCount = 0; + private readonly physicalAgentEligibility?: (agent: Agent) => boolean; + + public readonly requestedAgentAlias: string; + public readonly requestedModel: string; + public readonly callId: string; + private _requiredTokens: number; + + constructor( + private readonly service: SyntheticRoutingService, + options: Required> + & Pick, + ) { + this.requestedAgentAlias = options.requestedAgentAlias; + this.requestedModel = options.requestedModel; + this._requiredTokens = options.requiredTokens; + this.callId = options.callId; + this.physicalAgentEligibility = options.physicalAgentEligibility; + } + + get requiredTokens(): number { + return this._requiredTokens; + } + + get attemptedMembers(): ReadonlySet { + return this.attemptedMemberIds; + } + + /** Metadata for the current, or most recently failed, physical member of a synthetic call. */ + get routingMetadata(): Record | undefined { const selection = this.current?.synthetic ? this.current : this.lastFailedSelection; return selection ? this.service.metadataFor(selection) : undefined; } + + isPhysicalAgentEligible(agent: Agent): boolean { return this.physicalAgentEligibility?.(agent) ?? true; } + + async select(): Promise { + if (this.current) return this.current; + this.current = await this.service.select(this); + if (this.current.memberId) this.attemptedMemberIds.add(this.current.memberId); + return this.current; + } + + private failCurrent(reason: string): void { + if (this.current?.memberId) this.attemptFailures.set(this.current.memberId, reason); + if (this.current?.synthetic) this.lastFailedSelection = this.current; + this.current = undefined; + } + + failureReason(memberId: string): string | undefined { + return this.attemptFailures.get(memberId); + } + + /** Start a distinct logical call with the same virtual request and constraint. */ + fork(): SyntheticRoutingSession { + return this.service.begin({ + requestedAgentAlias: this.requestedAgentAlias, + requestedModel: this.requestedModel, + requiredTokens: this.requiredTokens, + physicalAgentEligibility: this.physicalAgentEligibility, + }); + } + + /** + * Finalize the prompt requirement after early model selection but before the + * first physical invocation. Retries then reuse this exact constraint. + */ + constrain(requiredTokens: number): void { + if (this.executionAttemptCount > 0) return; + this._requiredTokens = Math.max(this._requiredTokens, requiredTokens); + if (!this.current?.memberId) return; + const hardLimit = getModelHardLimit(`${this.current.physicalAgentAlias}:${this.current.physicalModel}`); + if (hardLimit >= this._requiredTokens) return; + // The member was pinned, not attempted. Let normal selection reconsider it + // with the now-known prompt requirement and do not report it as failed. + this.attemptedMemberIds.delete(this.current.memberId); + this.current = undefined; + } + + async analyze(prompt: string, options: AnalyzeOptions = {}): Promise { + this.constrain(estimateTokens(`${prompt}${options.context || ''}`) + DEFAULT_OUTPUT_RESERVE_TOKENS); + for (;;) { + const selection = await this.select(); + this.executionAttemptCount += 1; + const routingMetadata = this.service.metadataFor(selection); + try { + const result = await selection.physicalAgent.analyze(prompt, { + ...options, + model: selection.physicalModel, + metadata: selection.synthetic + ? { ...options.metadata, syntheticRouting: routingMetadata } + : options.metadata, + }); + if (result.success) return result; + const failure = resultFailure(result); + if (isNonRetryableSyntheticFailure(result) || !selection.synthetic) return result; + this.failCurrent(failure.message); + } catch (error) { + if (isNonRetryableSyntheticFailure(error) || !selection.synthetic) throw error; + this.failCurrent((error as Error).message); + } + } + } + + async executeTask(options: AgentTaskOptions): Promise { + this.constrain(estimateTaskRequiredTokens(options)); + for (;;) { + const selection = await this.select(); + this.executionAttemptCount += 1; + const attemptHistoryId = await this.service.recordAttempt(selection, options.taskId); + try { + const result = await selection.physicalAgent.executeTask({ + ...options, + model: selection.physicalModel, + isRetry: selection.attemptNumber > 1 || options.isRetry, + retryReason: selection.attemptNumber > 1 + ? this.failureReason([...this.attemptedMemberIds][this.attemptedMemberIds.size - 2] || '') || options.retryReason + : options.retryReason, + metadata: selection.synthetic + ? { ...options.metadata, syntheticRouting: this.service.metadataFor(selection) } + : options.metadata, + onContainerId: async (containerId, containerName) => { + await this.service.recordAttemptContainer(attemptHistoryId, selection, containerId, containerName); + await options.onContainerId?.(containerId, containerName); + }, + }); + if (result.success) return result; + const failure = resultFailure(result); + if (isNonRetryableSyntheticFailure(result) || !selection.synthetic) return result; + this.failCurrent(failure.message); + } catch (error) { + if (isNonRetryableSyntheticFailure(error) || !selection.synthetic) throw error; + this.failCurrent((error as Error).message); + } + } + } +} + +export class SyntheticRoutingService { + private readonly database: Knex; + private readonly loadConfigs: () => Promise; + private readonly getDirectAgent: (alias: string) => Agent | undefined; + private readonly usageProvider: SyntheticUsageSnapshotProvider; + + constructor(options: SyntheticRoutingServiceOptions) { + this.database = options.database ?? db; + this.loadConfigs = options.loadSyntheticConfigs ?? loadSyntheticAgents; + this.getDirectAgent = options.getDirectAgent; + this.usageProvider = options.usageSnapshotProvider ?? new AliasSpecificAgentTankSnapshotProvider(options.now); + } + + begin(options: BeginSyntheticRoutingOptions): SyntheticRoutingSession { + const requiredTokens = options.requiredTokens + ?? Math.max(0, options.promptTokens ?? 0) + (options.outputReserveTokens ?? DEFAULT_OUTPUT_RESERVE_TOKENS); + return new SyntheticRoutingSession(this, { + requestedAgentAlias: options.requestedAgentAlias, + requestedModel: options.requestedModel || '', + requiredTokens, + callId: options.callId || randomUUID(), + physicalAgentEligibility: options.physicalAgentEligibility, + }); + } + + private async loadSyntheticModel(alias: string, requestedModel: string, callId: string): Promise<{ + agent: SyntheticAgentConfig; + model: SyntheticModelConfig; + } | null> { + const agent = (await this.loadConfigs()).find(item => item.alias === alias); + if (!agent) return null; + const modelId = requestedModel || agent.defaultModel; + const model = agent.models.find(item => item.id === modelId); + if (!model) { + throw new SyntheticPoolExhaustedError(alias, modelId, callId, [{ + memberId: '', directAgentAlias: alias, model: modelId, eligible: false, reason: 'virtual model is not configured', + }]); + } + return { agent, model }; + } + + private async inspectMember( + member: SyntheticModelMember, + session: SyntheticRoutingSession, + ): Promise<{ diagnostic: SyntheticMemberDiagnostic; eligible?: EligibleMember }> { + const reject = (reason: string) => ({ + diagnostic: { memberId: member.id, directAgentAlias: member.directAgentAlias, model: member.model, eligible: false, reason }, + }); + if (!member.enabled) return reject('disabled'); + if (session.attemptedMembers.has(member.id)) return reject(session.failureReason(member.id) ? `attempt failed: ${session.failureReason(member.id)}` : 'already attempted'); + const agent = this.getDirectAgent(member.directAgentAlias); + if (!agent || !agent.config.enabled) return reject('direct agent unavailable or disabled'); + if (!session.isPhysicalAgentEligible(agent)) return reject('physical agent is ineligible for this routing session'); + if (!agent.config.supportedModels.includes(member.model)) return reject('model is not supported by the direct agent'); + const hardLimit = getModelHardLimit(`${member.directAgentAlias}:${member.model}`); + if (session.requiredTokens > hardLimit) return reject(`context window ${hardLimit} is below required ${session.requiredTokens} tokens`); + + let headroom = 1; + if (member.usageLimits) { + const snapshot = await this.usageProvider.getSnapshot(member.directAgentAlias); + if (!snapshot) return reject('fresh alias-specific usage data unavailable'); + const headrooms: number[] = []; + if (member.usageLimits.sessionMaxPercent !== undefined) { + if (snapshot.sessionPercent === undefined) return reject('session usage is unavailable'); + if (snapshot.sessionPercent >= member.usageLimits.sessionMaxPercent) return reject('session usage cap reached'); + headrooms.push((member.usageLimits.sessionMaxPercent - snapshot.sessionPercent) / member.usageLimits.sessionMaxPercent); + } + if (member.usageLimits.weeklyMaxPercent !== undefined) { + if (snapshot.weeklyPercent === undefined) return reject('weekly usage is unavailable'); + if (snapshot.weeklyPercent >= member.usageLimits.weeklyMaxPercent) return reject('weekly usage cap reached'); + headrooms.push((member.usageLimits.weeklyMaxPercent - snapshot.weeklyPercent) / member.usageLimits.weeklyMaxPercent); + } + headroom = headrooms.length ? Math.min(...headrooms) : 1; + } + + return { + diagnostic: { memberId: member.id, directAgentAlias: member.directAgentAlias, model: member.model, eligible: true, reason: 'eligible' }, + eligible: { member, agent, headroom }, + }; + } + + private async nextCursor(key: string): Promise { + return this.database.transaction(async trx => { + await trx('synthetic_routing_cursors').insert({ synthetic_model_key: key, cursor: 0 }) + .onConflict('synthetic_model_key').ignore(); + const row = await trx('synthetic_routing_cursors').where({ synthetic_model_key: key }).first<{ cursor: number | string }>(); + const cursor = Number(row?.cursor ?? 0); + await trx('synthetic_routing_cursors').where({ synthetic_model_key: key }).update({ cursor: cursor + 1, updated_at: trx.fn.now() }); + return cursor; + }); + } + + async select(session: SyntheticRoutingSession): Promise { + const synthetic = await this.loadSyntheticModel(session.requestedAgentAlias, session.requestedModel, session.callId); + if (!synthetic) { + const agent = this.getDirectAgent(session.requestedAgentAlias); + if (!agent) throw new Error(`Agent not found: ${session.requestedAgentAlias}`); + if (!session.isPhysicalAgentEligible(agent)) { + throw new Error(`Physical agent '${session.requestedAgentAlias}' is ineligible for this routing session`); + } + const physicalModel = session.requestedModel || agent.config.defaultModel; + if (!physicalModel) throw new Error(`No model configured for direct agent '${session.requestedAgentAlias}'`); + return { + virtualAgentAlias: session.requestedAgentAlias, virtualModel: physicalModel, + physicalAgent: agent, physicalAgentAlias: agent.config.alias, physicalModel, + callId: session.callId, attemptNumber: 1, selectionReason: 'direct agent request', + requiredTokens: session.requiredTokens, diagnostics: [], synthetic: false, + }; + } + + if (!synthetic.agent.enabled || !synthetic.model.enabled) { + throw new SyntheticPoolExhaustedError(synthetic.agent.alias, synthetic.model.id, session.callId, [{ + memberId: '', directAgentAlias: synthetic.agent.alias, model: synthetic.model.id, + eligible: false, reason: !synthetic.agent.enabled ? 'synthetic agent disabled' : 'synthetic model disabled', + }]); + } + + const inspected = await Promise.all(synthetic.model.members.map(member => this.inspectMember(member, session))); + const diagnostics = inspected.map(item => item.diagnostic); + const eligible = inspected.flatMap(item => item.eligible ? [item.eligible] : []); + if (eligible.length === 0) { + throw new SyntheticPoolExhaustedError(synthetic.agent.alias, synthetic.model.id, session.callId, diagnostics); + } + + const highestPriority = Math.max(...eligible.map(item => item.member.priority)); + const tier = eligible.filter(item => item.member.priority === highestPriority); + let chosen: EligibleMember; + let selectionReason: string; + if (synthetic.model.strategy === 'usage_based') { + chosen = [...tier].sort((a, b) => b.headroom - a.headroom || a.member.id.localeCompare(b.member.id))[0]; + selectionReason = `usage_based: priority ${highestPriority}, normalized headroom ${chosen.headroom.toFixed(4)}`; + } else { + const cursor = await this.nextCursor(`${synthetic.agent.id}:${synthetic.model.id}`); + chosen = tier[cursor % tier.length]; + selectionReason = `round_robin: priority ${highestPriority}, cursor ${cursor}`; + } + + return { + virtualAgentAlias: synthetic.agent.alias, + virtualModel: synthetic.model.id, + physicalAgent: chosen.agent, + physicalAgentAlias: chosen.member.directAgentAlias, + physicalModel: chosen.member.model, + memberId: chosen.member.id, + callId: session.callId, + attemptNumber: session.attemptedMembers.size + 1, + selectionReason, + requiredTokens: session.requiredTokens, + diagnostics, + synthetic: true, + }; + } + + /** + * Probe pool availability without consuming the persisted round-robin cursor. + * Members are checked in priority order so health probes reflect the same + * failover tiers as workload routing, while remaining side-effect free. + */ + async healthCheck(session: SyntheticRoutingSession): Promise { + const synthetic = await this.loadSyntheticModel(session.requestedAgentAlias, session.requestedModel, session.callId); + if (!synthetic) { + const agent = this.getDirectAgent(session.requestedAgentAlias); + if (!agent?.config.enabled || !session.isPhysicalAgentEligible(agent)) return false; + try { + return await agent.healthCheck(); + } catch { + return false; + } + } + + if (!synthetic.agent.enabled || !synthetic.model.enabled) return false; + + const inspected = await Promise.all(synthetic.model.members.map(member => this.inspectMember(member, session))); + const eligible = inspected.flatMap(item => item.eligible ? [item.eligible] : []); + const candidates = [...eligible].sort((a, b) => { + const priority = b.member.priority - a.member.priority; + if (priority !== 0) return priority; + if (synthetic.model.strategy === 'usage_based') { + const headroom = b.headroom - a.headroom; + if (headroom !== 0) return headroom; + } + return a.member.id.localeCompare(b.member.id); + }); + + for (const candidate of candidates) { + try { + if (await candidate.agent.healthCheck()) return true; + } catch { + // A failed probe makes only this member unavailable; keep checking the + // remaining members and lower-priority failover tiers. + } + } + return false; + } + + metadataFor(selection: SyntheticPhysicalSelection): Record { + return { + virtualAgentAlias: selection.virtualAgentAlias, + virtualModel: selection.virtualModel, + physicalAgentAlias: selection.physicalAgentAlias, + physicalModel: selection.physicalModel, + memberId: selection.memberId, + callId: selection.callId, + attemptNumber: selection.attemptNumber, + selectionReason: selection.selectionReason, + requiredTokens: selection.requiredTokens, + }; + } + + async recordAttempt(selection: SyntheticPhysicalSelection, taskId?: string): Promise { + if (!selection.synthetic || !taskId) return null; + try { + const task = await this.database('tasks').where({ task_id: taskId }).first('task_id'); + if (!task) return null; + const [inserted] = await this.database('task_history').insert({ + task_id: taskId, + state: 'claude_execution', + timestamp: new Date().toISOString(), + reason: `Synthetic routing attempt ${selection.attemptNumber}`, + metadata: JSON.stringify({ syntheticRouting: this.metadataFor(selection) }), + }).returning('history_id'); + return typeof inserted === 'object' + ? Number((inserted as { history_id: number }).history_id) + : Number(inserted); + } catch (error) { + logger.warn({ taskId, callId: selection.callId, error: (error as Error).message }, 'Could not persist synthetic routing attempt history'); + return null; + } + } + + async recordAttemptContainer( + historyId: number | null, + selection: SyntheticPhysicalSelection, + containerId: string, + containerName: string, + ): Promise { + if (!historyId || !selection.synthetic) return; + try { + await this.database('task_history').where({ history_id: historyId }).update({ + metadata: JSON.stringify({ + syntheticRouting: this.metadataFor(selection), + containerId, + containerName, + }), + }); + } catch (error) { + logger.warn({ historyId, callId: selection.callId, error: (error as Error).message }, 'Could not attach container identity to synthetic routing attempt'); + } + } +} diff --git a/packages/core/src/services/syntheticRoutingTypes.ts b/packages/core/src/services/syntheticRoutingTypes.ts new file mode 100644 index 000000000..2ffd38a35 --- /dev/null +++ b/packages/core/src/services/syntheticRoutingTypes.ts @@ -0,0 +1,102 @@ +import type { Knex } from 'knex'; +import type { SyntheticAgentConfig } from '@propr/shared'; +import type { Agent } from '../agents/types.js'; + +export interface SyntheticUsageSnapshot { + directAgentAlias: string; + capturedAt: Date; + sessionPercent?: number; + weeklyPercent?: number; +} + +export interface SyntheticUsageSnapshotProvider { + getSnapshot(directAgentAlias: string): Promise; +} + +export interface SyntheticMemberDiagnostic { + memberId: string; + directAgentAlias: string; + model: string; + eligible: boolean; + reason: string; +} + +export interface SyntheticPhysicalSelection { + virtualAgentAlias: string; + virtualModel: string; + physicalAgent: Agent; + physicalAgentAlias: string; + physicalModel: string; + memberId?: string; + callId: string; + attemptNumber: number; + selectionReason: string; + requiredTokens: number; + diagnostics: SyntheticMemberDiagnostic[]; + synthetic: boolean; +} + +export interface BeginSyntheticRoutingOptions { + requestedAgentAlias: string; + requestedModel?: string; + /** Prompt plus output/runtime reserve. This constraint is immutable for retries. */ + requiredTokens?: number; + promptTokens?: number; + outputReserveTokens?: number; + callId?: string; + /** Reject physical agents that cannot satisfy call-specific runtime constraints. */ + physicalAgentEligibility?: (agent: Agent) => boolean; +} + +export interface SyntheticRoutingServiceOptions { + database?: Knex; + loadSyntheticConfigs?: () => Promise; + getDirectAgent: (alias: string) => Agent | undefined; + usageSnapshotProvider?: SyntheticUsageSnapshotProvider; + now?: () => Date; +} + +export class SyntheticPoolExhaustedError extends Error { + constructor( + public readonly virtualAgentAlias: string, + public readonly virtualModel: string, + public readonly callId: string, + public readonly diagnostics: SyntheticMemberDiagnostic[], + ) { + const details = diagnostics.length === 0 + ? 'no configured members' + : diagnostics.map(item => `${item.directAgentAlias}:${item.model} (${item.reason})`).join('; '); + super(`Synthetic pool exhausted for '${virtualAgentAlias}:${virtualModel}' [call ${callId}]: ${details}`); + this.name = 'SyntheticPoolExhaustedError'; + } +} + +export function isNonRetryableSyntheticFailure(error: unknown): boolean { + const value = error as { + name?: string; + code?: string; + message?: string; + error?: string; + errorName?: string; + errorCode?: string; + terminationReason?: string; + logs?: string; + }; + const names = [value?.name, value?.errorName]; + const codes = [value?.code, value?.errorCode]; + const terminationReason = typeof value?.terminationReason === 'string' + ? value.terminationReason.trim().toLowerCase() + : undefined; + const message = [value?.message, value?.error, value?.logs] + .filter((item): item is string => typeof item === 'string' && item.length > 0) + .join('\n') || String(error || ''); + // Generic abort names/codes are also emitted for provider timeouts and + // interrupted transports. Only explicit task-cancellation errors stop + // failover; an unqualified AbortError/ABORT_ERR/ERR_CANCELED remains retryable. + if (names.some(name => name && ['ExecutionAbortedError', 'IndexingCancelledError', 'SecurityException', 'ContextTokenLimitError'].includes(name))) return true; + if (codes.some(code => code && ['SECURITY_POLICY_VIOLATION', 'INVALID_CONFIGURATION', 'PROMPT_TOO_LARGE'].includes(code))) return true; + if (terminationReason && ['user_cancelled', 'user_canceled'].includes(terminationReason)) return true; + const explicitCancellation = /\btask\s+(?:was\s+)?(?:aborted|cancelled|canceled)\b|\b(?:execution|request|operation|call)\s+(?:was\s+)?(?:aborted|cancelled|canceled)\s+by\s+(?:the\s+)?(?:user|operator)\b|\b(?:aborted|cancelled|canceled)\s+by\s+(?:the\s+)?(?:user|operator)\b|\b(?:user|operator)\s+(?:requested\s+)?(?:aborted|cancelled|canceled|cancell?ation)\b|\b(?:user|task)[-_\s]+cancell?ation\b|\bcancell?ation\s+(?:was\s+)?requested\s+by\s+(?:the\s+)?(?:user|operator)\b/i; + if (explicitCancellation.test(message)) return true; + return /security[- ]policy|security violation|invalid (?:user )?configuration|prompt (?:is )?too (?:large|long)|exceeds (?:the )?(?:model )?context window|context token limit/i.test(message); +} diff --git a/packages/core/src/services/syntheticUsageSnapshotProvider.ts b/packages/core/src/services/syntheticUsageSnapshotProvider.ts new file mode 100644 index 000000000..6086b55cf --- /dev/null +++ b/packages/core/src/services/syntheticUsageSnapshotProvider.ts @@ -0,0 +1,57 @@ +import { loadAgentTankSettings } from '../config/configManager.js'; +import logger from '../utils/logger.js'; +import { getStatus, type AgentStatusResponse } from './agentTankService.js'; +import type { SyntheticUsageSnapshot, SyntheticUsageSnapshotProvider } from './syntheticRoutingTypes.js'; + +const DEFAULT_USAGE_FRESHNESS_MS = 5 * 60_000; + +function finitePercent(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 100 + ? value + : undefined; +} + +function nestedPercent(usage: Record, names: string[]): number | undefined { + for (const name of names) { + const value = usage[name]; + if (value && typeof value === 'object' && !Array.isArray(value)) { + const percent = finitePercent((value as Record).percent) + ?? finitePercent((value as Record).percentUsed); + if (percent !== undefined) return percent; + } + } + return undefined; +} + +/** Provides fresh usage data only when Agent Tank names the requested direct alias exactly. */ +export class AliasSpecificAgentTankSnapshotProvider implements SyntheticUsageSnapshotProvider { + constructor( + private readonly now: () => Date = () => new Date(), + private readonly freshnessMs = Number(process.env.SYNTHETIC_USAGE_FRESHNESS_MS) || DEFAULT_USAGE_FRESHNESS_MS, + private readonly fetchStatus: (alias: string) => Promise = getStatus, + ) {} + + async getSnapshot(directAgentAlias: string): Promise { + const settings = await loadAgentTankSettings(); + if (!settings.enabled) return null; + + let status: AgentStatusResponse; + try { + status = await this.fetchStatus(directAgentAlias); + } catch (error) { + logger.warn({ directAgentAlias, error: (error as Error).message }, 'Alias-specific usage snapshot unavailable'); + return null; + } + + if (status.name !== directAgentAlias || status.error || status.isRefreshing || !status.lastUpdated) return null; + const capturedAt = new Date(status.lastUpdated); + if (!Number.isFinite(capturedAt.getTime()) || this.now().getTime() - capturedAt.getTime() > this.freshnessMs) return null; + + return { + directAgentAlias, + capturedAt, + sessionPercent: nestedPercent(status.usage, ['session']), + weeklyPercent: nestedPercent(status.usage, ['weekly', 'weeklyAll', 'week']), + }; + } +} diff --git a/packages/core/src/services/taskPlanning/llmCalling.ts b/packages/core/src/services/taskPlanning/llmCalling.ts index b4a750eb8..ffd13416c 100644 --- a/packages/core/src/services/taskPlanning/llmCalling.ts +++ b/packages/core/src/services/taskPlanning/llmCalling.ts @@ -93,7 +93,7 @@ export async function callLLMForPlan(opts: CallLLMOptions): Promise(repairedResponse); diff --git a/packages/core/src/services/taskPlanning/refinement.ts b/packages/core/src/services/taskPlanning/refinement.ts index 659387a76..d27ea7a9f 100644 --- a/packages/core/src/services/taskPlanning/refinement.ts +++ b/packages/core/src/services/taskPlanning/refinement.ts @@ -10,6 +10,7 @@ import { estimateLlmDuration } from '../../utils/llmEstimation.js'; import { estimateTokens } from '../../utils/tokenCalculation.js'; import { loadSettings } from '../../config/configManager.js'; import { resolveConfiguredModel } from '../../config/configuredModel.js'; +import { AgentRegistry } from '../../agents/AgentRegistry.js'; import { PlanningFailedError, getRawInputCharLimit, type MinimalLogger } from '../planning/index.js'; import type { RefinePlanOptions, RefinePlanResult, RefinePlanEstimation } from './types.js'; @@ -177,7 +178,18 @@ export async function refinePlan(options: RefinePlanOptions): Promise // Load planner models from settings (used as defaults) const settings = await loadSettings(); - const contextModel = await resolveConfiguredModel(settings.planner_context_model); - const defaultGenerationModel = await resolveConfiguredModel(settings.planner_generation_model); - correlatedLogger.info({ draftId, contextModel, defaultGenerationModel }, 'Starting plan generation'); + const requestedContextModel = await resolveConfiguredModel(settings.planner_context_model); + const requestedDefaultGenerationModel = await resolveConfiguredModel(settings.planner_generation_model); + const registry = AgentRegistry.getInstance(); + await registry.ensureInitialized(); + correlatedLogger.info({ draftId, contextModel: requestedContextModel, defaultGenerationModel: requestedDefaultGenerationModel }, 'Starting plan generation'); const draft = await db('task_drafts').where({ draft_id: draftId }).first(); if (!draft) throw new Error(`Draft not found: ${draftId}`); @@ -91,9 +94,18 @@ export async function generatePlan(options: GeneratePlanOptions): Promise // Parse context_config - generationModel from draft config takes priority over global setting const parsedContextConfig = parseDraftContextConfig(draft.context_config, draftId, correlatedLogger); - const config = parseContextConfig(parsedContextConfig, defaultGenerationModel); + const requestedGenerationModel = configModelFromDraft(parsedContextConfig) || requestedDefaultGenerationModel; + const generationRoute = registry.beginRoutingSession(parseRoutingModel(requestedGenerationModel)); + const generationSelection = await generationRoute.select(); + const generationModel = `${generationSelection.physicalAgentAlias}:${generationSelection.physicalModel}`; + const contextRoute = registry.beginRoutingSession(parseRoutingModel(requestedContextModel)); + const contextSelection = await contextRoute.select(); + const contextModel = `${contextSelection.physicalAgentAlias}:${contextSelection.physicalModel}`; + const config = parseContextConfig( + { ...parsedContextConfig, generationModel } as NonNullable[0]>, + generationModel, + ); // Use the effective generation model: draft config > global setting - const generationModel = config.generationModel || defaultGenerationModel; correlatedLogger.info({ draftId, granularity: config.granularity, contextLevel: config.contextLevel, tokenLimit: config.tokenLimit, rawContextLevel: parsedContextConfig?.contextLevel, generationModel, draftGenerationModel: config.generationModel }, 'Parsed context config for plan generation'); // Parse and load attachments after context config so images can be sized for the selected token budget. @@ -107,7 +119,7 @@ export async function generatePlan(options: GeneratePlanOptions): Promise await checkoutBaseBranch(worktreePath, config.baseBranch, correlatedLogger); - const relevantFilePaths = await findFilesForPlan({ draftId, worktreePath, draft, manualFiles: config.manualFiles, autoFiles: config.autoFiles, correlationId, contextModel }); + const relevantFilePaths = await findFilesForPlan({ draftId, worktreePath, draft, manualFiles: config.manualFiles, autoFiles: config.autoFiles, correlationId, contextModel, routingSession: contextRoute }); // Calculate estimated duration for context gathering based on file count const estimatedContextDuration = Math.min(5000 + (relevantFilePaths.length * 50), 30000); @@ -151,7 +163,8 @@ export async function generatePlan(options: GeneratePlanOptions): Promise const { plan, enforcementMetadata } = await callLLMForPlan({ draftId, runId, fullContext: fullContext!, worktreePath, githubToken, repository: draft.repository, - correlationId, tokenLimit: config.tokenLimit, model: generationModel, granularity: config.granularity + correlationId, tokenLimit: config.tokenLimit, model: generationModel, granularity: config.granularity, + routingSession: generationRoute, }); correlatedLogger.info({ taskCount: plan.length }, 'Validating and repairing file paths'); @@ -172,7 +185,8 @@ export async function generatePlan(options: GeneratePlanOptions): Promise const finalTrace = await updateGenerationTrace(draftId, 'llm', 'completed', { runId }); - const updatedContextConfig = { ...parsedContextConfig, generationModel, granularityEnforcement: enforcementMetadata }; + // Persist the virtual request, never the implementation detail selected for this call. + const updatedContextConfig = { ...parsedContextConfig, generationModel: requestedGenerationModel, granularityEnforcement: enforcementMetadata }; // Build initial chat history with user prompt summary and assistant confirmation const chatHistory = buildInitialChatHistory(draft.initial_prompt, validatedPlan.length); @@ -218,3 +232,16 @@ export async function generatePlan(options: GeneratePlanOptions): Promise return validatedPlan; } + +function configModelFromDraft(value: unknown): string | undefined { + if (!value || typeof value !== 'object') return undefined; + const model = (value as { generationModel?: unknown }).generationModel; + return typeof model === 'string' && model.trim() ? model.trim() : undefined; +} + +function parseRoutingModel(value: string): { requestedAgentAlias: string; requestedModel?: string } { + const separator = value.indexOf(':'); + return separator < 0 + ? { requestedAgentAlias: value } + : { requestedAgentAlias: value.slice(0, separator), requestedModel: value.slice(separator + 1) }; +} diff --git a/packages/core/src/webhook/checkRunHandler.ts b/packages/core/src/webhook/checkRunHandler.ts index c07da4d17..3d4f741f3 100644 --- a/packages/core/src/webhook/checkRunHandler.ts +++ b/packages/core/src/webhook/checkRunHandler.ts @@ -15,6 +15,11 @@ import { type MergePRResult, type PRAutoMergeInfo } from './checkRunHelpers.js'; +import { + extractCheckRunFailure, + extractStatusFailure, + postCiFailureFollowup, +} from './ciFailureFollowup.js'; import type { CheckRunEvent } from '@octokit/webhooks-types'; export interface StatusEventPayload { @@ -22,6 +27,8 @@ export interface StatusEventPayload { state: string; repository: { full_name: string }; context?: string; + description?: string | null; + target_url?: string | null; [key: string]: unknown; } @@ -220,7 +227,8 @@ export async function reevaluatePRAutoMerge( /** * Handles check_run webhook events. - * When a check run completes successfully, checks if the PR should be auto-merged. + * Successful check runs drive auto-merge/Ultrafix. Failed check runs can post an + * automatic follow-up comment when that repository has opted in. */ export async function handleCheckRunEvent( payload: CheckRunEvent, @@ -240,15 +248,41 @@ export async function handleCheckRunEvent( if (payload.action !== 'completed') return; + const pullRequests = payload.check_run.pull_requests; + if (!pullRequests || pullRequests.length === 0) { + log.debug({ owner, repoName }, 'check_run skipped: no associated PRs'); + return; + } + const conclusion = payload.check_run.conclusion; - if (conclusion !== 'success' && conclusion !== 'skipped') { - log.debug({ owner, repoName, conclusion }, 'check_run skipped: not success/skipped'); + const failure = extractCheckRunFailure(payload); + if (failure) { + for (const pr of pullRequests) { + try { + const currentPrHead = await getCurrentPRHead(owner, repoName, pr.number); + if (currentPrHead !== failure.sha) { + log.debug({ + owner, + repoName, + prNumber: pr.number, + failedCiSha: failure.sha, + currentPrHead, + }, 'Failed check run SHA does not match current PR head, skipping follow-up'); + continue; + } + await postCiFailureFollowup({ owner, repo: repoName, prNumber: pr.number, evidence: failure }, correlationId); + } catch (error) { + log.warn( + { owner, repoName, prNumber: pr.number, error: (error as Error).message }, + 'Failed to post automatic failed-CI follow-up', + ); + } + } return; } - const pullRequests = payload.check_run.pull_requests; - if (!pullRequests || pullRequests.length === 0) { - log.debug({ owner, repoName }, 'check_run skipped: no associated PRs'); + if (conclusion !== 'success' && conclusion !== 'skipped') { + log.debug({ owner, repoName, conclusion }, 'check_run skipped: not success/skipped'); return; } @@ -285,8 +319,8 @@ export async function handleCheckRunEvent( /** * Handles legacy commit `status` webhook events. - * When a commit status reports success, looks up associated open PRs - * and fires the ultrafix hook so deferred continuations can resume. + * Failed/error statuses can post an opted-in automatic follow-up. Successful + * statuses fire the Ultrafix hook so deferred continuations can resume. */ export async function handleStatusEvent( payload: StatusEventPayload, @@ -297,9 +331,9 @@ export async function handleStatusEvent( log.debug({ owner, repoName, state: payload.state, sha: payload.sha, context: payload.context }, 'status event received'); - if (payload.state !== 'success') return; - - if (!_ultrafixCheckRunHook) return; + const failure = extractStatusFailure(payload); + if (!failure && payload.state !== 'success') return; + if (!failure && !_ultrafixCheckRunHook) return; const prs = await findPRsForCommit(owner, repoName, payload.sha); if (prs.length === 0) { @@ -308,8 +342,31 @@ export async function handleStatusEvent( } for (const pr of prs) { + if (failure) { + try { + const currentPrHead = await getCurrentPRHead(owner, repoName, pr.number); + if (currentPrHead !== failure.sha) { + log.debug({ + owner, + repoName, + prNumber: pr.number, + failedCiSha: failure.sha, + currentPrHead, + }, 'Failed status SHA does not match current PR head, skipping follow-up'); + continue; + } + await postCiFailureFollowup({ owner, repo: repoName, prNumber: pr.number, evidence: failure }, correlationId); + } catch (error) { + log.warn( + { owner, repoName, prNumber: pr.number, error: (error as Error).message }, + 'Failed to post automatic failed-CI status follow-up', + ); + } + continue; + } + try { - await _ultrafixCheckRunHook(owner, repoName, pr.number, payload.sha); + await _ultrafixCheckRunHook!(owner, repoName, pr.number, payload.sha); } catch (error) { log.warn({ owner, repoName, prNumber: pr.number, error: (error as Error).message }, 'Ultrafix status hook failed'); } diff --git a/packages/core/src/webhook/ciFailureFollowup.ts b/packages/core/src/webhook/ciFailureFollowup.ts new file mode 100644 index 000000000..7ad36145a --- /dev/null +++ b/packages/core/src/webhook/ciFailureFollowup.ts @@ -0,0 +1,355 @@ +import { createHash } from 'node:crypto'; +import type { CheckRunEvent } from '@octokit/webhooks-types'; +import type { Redis } from 'ioredis'; +import { getAuthenticatedOctokit } from '../auth/githubAuth.js'; +import { getBotUsername, isAutoCiFollowupEnabledForRepository } from '../daemon/configLoader.js'; +import logger from '../utils/logger.js'; +import { withRetry } from '../utils/retryHandler.js'; +import { getUltrafixStateRedis } from './checkRunHelpers.js'; + +export const CI_FAILURE_FOLLOWUP_MARKER_PREFIX = '/gu; +const DEDUPE_TTL_SECONDS = 30 * 24 * 60 * 60; +const MAX_EXCERPT_LENGTH = 1800; + +const FAILING_CHECK_RUN_CONCLUSIONS = new Set([ + 'action_required', + 'failure', + 'startup_failure', + 'timed_out', +]); + +export interface CiFailureAnnotation { + annotation_level?: string | null; + path?: string | null; + start_line?: number | null; + end_line?: number | null; + title?: string | null; + message?: string | null; + raw_details?: string | null; +} + +export interface CiFailureEvidence { + kind: 'check_run' | 'status'; + name: string; + state: string; + sha: string; + url: string; + source: string; + fallbackExcerpt?: string; + checkRunId?: number; + annotationsCount?: number; +} + +export interface CiFailureFollowupRequest { + owner: string; + repo: string; + prNumber: number; + evidence: CiFailureEvidence; +} + +interface CiFailureOctokit { + request: (route: string, parameters: Record) => Promise<{ data: unknown }>; + paginate?: (route: string, parameters: Record) => Promise; +} + +type DedupeRedis = Pick; + +export interface CiFailureFollowupDependencies { + isEnabled?: (owner: string, repo: string) => Promise; + getOctokit?: () => Promise; + redisClient?: DedupeRedis; +} + +export interface CiFailureFollowupResult { + posted: boolean; + reason: 'posted' | 'disabled' | 'duplicate'; + body?: string; +} + +interface StatusFailurePayload { + sha: string; + state: string; + context?: string; + description?: string | null; + target_url?: string | null; + repository: { full_name: string }; +} + +export function isFailingCheckRunConclusion(conclusion: string | null | undefined): boolean { + return conclusion != null && FAILING_CHECK_RUN_CONCLUSIONS.has(conclusion.toLowerCase()); +} + +export function extractCheckRunFailure(payload: CheckRunEvent): CiFailureEvidence | null { + if (payload.action !== 'completed' || !isFailingCheckRunConclusion(payload.check_run.conclusion)) return null; + + const output = payload.check_run.output; + const fallbackExcerpt = joinUsefulText([output.title, output.summary, output.text]); + const [owner, repo] = payload.repository.full_name.split('/'); + return { + kind: 'check_run', + name: payload.check_run.name || 'Unnamed check run', + state: payload.check_run.conclusion as string, + sha: payload.check_run.head_sha, + url: payload.check_run.details_url + || payload.check_run.html_url + || `https://github.com/${owner}/${repo}/commit/${payload.check_run.head_sha}`, + source: `check-run:${payload.check_run.name || payload.check_run.id}`, + fallbackExcerpt: fallbackExcerpt || undefined, + checkRunId: payload.check_run.id, + annotationsCount: output.annotations_count, + }; +} + +export function extractStatusFailure(payload: StatusFailurePayload): CiFailureEvidence | null { + const state = payload.state.toLowerCase(); + if (state !== 'failure' && state !== 'error') return null; + + const context = payload.context?.trim() || 'Commit status'; + return { + kind: 'status', + name: context, + state, + sha: payload.sha, + url: payload.target_url || `https://github.com/${payload.repository.full_name}/commit/${payload.sha}`, + source: `status:${context}`, + fallbackExcerpt: payload.description?.trim() || undefined, + }; +} + +export function buildCiFailureDedupeKey(request: CiFailureFollowupRequest): string { + const identity = [ + request.owner.toLowerCase(), + request.repo.toLowerCase(), + request.prNumber, + request.evidence.sha.toLowerCase(), + request.evidence.source.toLowerCase(), + ].join('\0'); + return createHash('sha256').update(identity).digest('hex'); +} + +export function buildCiFailureFollowupMarker(dedupeKey: string): string { + return `${CI_FAILURE_FOLLOWUP_MARKER_PREFIX} key="${dedupeKey}" -->`; +} + +export function isCiFailureFollowupComment(body: string | null | undefined): boolean { + if (!body) return false; + CI_FAILURE_FOLLOWUP_MARKER_RE.lastIndex = 0; + return CI_FAILURE_FOLLOWUP_MARKER_RE.test(body); +} + +export function stripCiFailureFollowupMarker(body: string): string { + CI_FAILURE_FOLLOWUP_MARKER_RE.lastIndex = 0; + return body.replace(CI_FAILURE_FOLLOWUP_MARKER_RE, '').trim(); +} + +export function buildCiFailureFollowupComment( + request: CiFailureFollowupRequest, + failureExcerpt: string | undefined, + dedupeKey = buildCiFailureDedupeKey(request), +): string { + const { evidence } = request; + const excerpt = truncate(failureExcerpt?.trim() || evidence.fallbackExcerpt?.trim() || 'No failure output was provided by the CI service.'); + const shortSha = evidence.sha.slice(0, 12); + + return [ + `CI failed: **${escapeInlineMarkdown(evidence.name)}**`, + '', + 'Please investigate and fix this CI failure.', + '', + `- Check: \`${escapeInlineCode(evidence.name)}\``, + `- Result: \`${escapeInlineCode(evidence.state)}\``, + `- Commit: [\`${shortSha}\`](${evidence.url}) (\`${escapeInlineCode(evidence.sha)}\`)`, + `- Details: [View CI failure](${evidence.url})`, + '', + '**Failure evidence**', + ...excerpt.split('\n').map(line => `> ${line || ' '}`), + '', + buildCiFailureFollowupMarker(dedupeKey), + ].join('\n'); +} + +/** + * Posts one bot-authored follow-up for a failing CI source. A Redis NX claim + * closes concurrent webhook races, while the marker scan makes deduplication + * survive process restarts and Redis expiry. + */ +export async function postCiFailureFollowup( + request: CiFailureFollowupRequest, + correlationId: string, + dependencies: CiFailureFollowupDependencies = {}, +): Promise { + const log = logger.withCorrelation(correlationId); + const isEnabled = dependencies.isEnabled ?? isAutoCiFollowupEnabledForRepository; + if (!await isEnabled(request.owner, request.repo)) { + log.debug({ owner: request.owner, repo: request.repo, prNumber: request.prNumber }, 'Automatic failed-CI follow-up is disabled'); + return { posted: false, reason: 'disabled' }; + } + + const getOctokit = dependencies.getOctokit + ?? (async () => await getAuthenticatedOctokit() as unknown as CiFailureOctokit); + const octokit = await getOctokit(); + const dedupeKey = buildCiFailureDedupeKey(request); + const redis = dependencies.redisClient ?? getUltrafixStateRedis(); + const redisKey = `ci-failure-followup:${dedupeKey}`; + let claimed = false; + + try { + const claim = await redis.set(redisKey, Date.now().toString(), 'EX', DEDUPE_TTL_SECONDS, 'NX'); + if (claim !== 'OK') { + log.debug({ ...failureLogContext(request), dedupeKey }, 'Automatic failed-CI follow-up already claimed'); + return { posted: false, reason: 'duplicate' }; + } + claimed = true; + + try { + if (await hasExistingFollowupComment(octokit, request, dedupeKey)) { + log.debug({ ...failureLogContext(request), dedupeKey }, 'Automatic failed-CI follow-up comment already exists'); + return { posted: false, reason: 'duplicate' }; + } + } catch (error) { + // The atomic Redis claim still protects concurrent/redelivered + // events. A transient comment-list failure should not hide a new CI + // failure from the agent. + log.warn({ error: (error as Error).message }, 'Could not scan PR comments for an existing failed-CI follow-up'); + } + + let annotations: CiFailureAnnotation[] = []; + if (request.evidence.kind === 'check_run' && request.evidence.checkRunId != null) { + try { + annotations = await loadCheckRunAnnotations(octokit, request); + } catch (error) { + // Output summaries are carried in the webhook and remain useful + // when the annotations endpoint is temporarily unavailable. + log.warn({ error: (error as Error).message }, 'Could not load check-run annotations; using webhook output instead'); + } + } + const annotationExcerpt = buildAnnotationExcerpt(annotations); + const body = buildCiFailureFollowupComment(request, annotationExcerpt, dedupeKey); + + await withRetry( + () => octokit.request('POST /repos/{owner}/{repo}/issues/{issue_number}/comments', { + owner: request.owner, + repo: request.repo, + issue_number: request.prNumber, + body, + }), + { maxAttempts: 3, baseDelay: 1000, maxDelay: 5000, exponentialBase: 2, correlationId }, + `post_ci_failure_followup_${request.owner}_${request.repo}_${request.prNumber}`, + ); + + log.info({ ...failureLogContext(request), dedupeKey }, 'Posted automatic failed-CI follow-up comment'); + return { posted: true, reason: 'posted', body }; + } catch (error) { + if (claimed) { + try { + await redis.del(redisKey); + } catch (cleanupError) { + log.warn({ cleanupError }, 'Failed to release failed-CI follow-up dedupe claim after an error'); + } + } + throw error; + } +} + +async function hasExistingFollowupComment( + octokit: CiFailureOctokit, + request: CiFailureFollowupRequest, + dedupeKey: string, +): Promise { + if (!octokit.paginate) return false; + const comments = await octokit.paginate('GET /repos/{owner}/{repo}/issues/{issue_number}/comments', { + owner: request.owner, + repo: request.repo, + issue_number: request.prNumber, + per_page: 100, + }); + const marker = buildCiFailureFollowupMarker(dedupeKey); + const configuredBotUsernames = new Set( + [getBotUsername(), process.env.GITHUB_BOT_USERNAME, 'propr-dev[bot]'].filter(Boolean), + ); + return comments.some(comment => { + if (!isRecord(comment) || typeof comment.body !== 'string' || !comment.body.includes(marker)) return false; + const user = isRecord(comment.user) ? comment.user : null; + const login = user && typeof user.login === 'string' ? user.login : ''; + return configuredBotUsernames.has(login); + }); +} + +async function loadCheckRunAnnotations( + octokit: CiFailureOctokit, + request: CiFailureFollowupRequest, +): Promise { + if ((request.evidence.annotationsCount ?? 0) <= 0) return []; + const parameters = { + owner: request.owner, + repo: request.repo, + check_run_id: request.evidence.checkRunId as number, + per_page: 100, + }; + const data = octokit.paginate + ? await octokit.paginate('GET /repos/{owner}/{repo}/check-runs/{check_run_id}/annotations', parameters) + : (await octokit.request('GET /repos/{owner}/{repo}/check-runs/{check_run_id}/annotations', parameters)).data; + return Array.isArray(data) ? data.filter(isRecord) as CiFailureAnnotation[] : []; +} + +export function buildAnnotationExcerpt(annotations: CiFailureAnnotation[]): string | undefined { + const usefulAnnotations = [...annotations] + .sort((left, right) => annotationPriority(left) - annotationPriority(right)) + .filter(annotation => annotation.message || annotation.title || annotation.raw_details) + .slice(0, 3); + if (usefulAnnotations.length === 0) return undefined; + + return truncate(usefulAnnotations.map(annotation => { + const location = annotation.path + ? `${annotation.path}${formatAnnotationLines(annotation.start_line, annotation.end_line)}` + : ''; + return joinUsefulText([ + joinUsefulText([location, annotation.title], ' — '), + annotation.message, + annotation.raw_details, + ]); + }).filter(Boolean).join('\n\n')); +} + +function annotationPriority(annotation: CiFailureAnnotation): number { + if (annotation.annotation_level === 'failure') return 0; + if (annotation.annotation_level === 'warning') return 1; + return 2; +} + +function formatAnnotationLines(startLine?: number | null, endLine?: number | null): string { + if (startLine == null) return ''; + return endLine != null && endLine !== startLine ? `:${startLine}-${endLine}` : `:${startLine}`; +} + +function joinUsefulText(values: Array, separator = '\n'): string { + return values.map(value => value?.trim()).filter((value): value is string => Boolean(value)).join(separator); +} + +function truncate(value: string): string { + if (value.length <= MAX_EXCERPT_LENGTH) return value; + return `${value.slice(0, MAX_EXCERPT_LENGTH - 1).trimEnd()}…`; +} + +function escapeInlineCode(value: string): string { + return value.replace(/([\\`])/gu, '\\$1'); +} + +function escapeInlineMarkdown(value: string): string { + return value.replace(/([\\*_`[\]])/gu, '\\$1'); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function failureLogContext(request: CiFailureFollowupRequest): Record { + return { + owner: request.owner, + repo: request.repo, + prNumber: request.prNumber, + sha: request.evidence.sha, + source: request.evidence.source, + }; +} diff --git a/packages/core/src/webhook/commentEventHandler.ts b/packages/core/src/webhook/commentEventHandler.ts index f4a7560e6..1229748c0 100644 --- a/packages/core/src/webhook/commentEventHandler.ts +++ b/packages/core/src/webhook/commentEventHandler.ts @@ -22,6 +22,7 @@ import { MODEL_INFO_MAP } from '../config/modelDefinitions.js'; import { getBotUsername } from '../daemon/configLoader.js'; import { AgentRegistry } from '../agents/AgentRegistry.js'; import type { DeliveryDisposition } from '../intake/routingWebSocketProtocol.js'; +import { isCiFailureFollowupComment, stripCiFailureFollowupMarker } from './ciFailureFollowup.js'; export interface UltrafixDeps { loadUltrafixRatingGoal: () => Promise; @@ -586,6 +587,33 @@ function acceptedCommentDisposition(commentId: number, seatConsumed: boolean): D }; } +function prepareCiFollowupComment( + comment: PRComment, + commentAuthor: string, + configuredBotUsernames: Set, +): { comment: PRComment; isSystemCiFollowupComment: boolean } { + const isSystemCiFollowupComment = configuredBotUsernames.has(commentAuthor) + && isCiFailureFollowupComment(comment.body); + return { + isSystemCiFollowupComment, + comment: isSystemCiFollowupComment + ? { ...comment, body: stripCiFailureFollowupMarker(comment.body) } + : comment, + }; +} + +function shouldFilterSystemComment(shouldFilter: boolean, isSystemUltrafixComment: boolean, isSystemCiFollowupComment: boolean): boolean { + return shouldFilter && !isSystemUltrafixComment && !isSystemCiFollowupComment; +} + +function shouldIgnoreSystemComment(shouldIgnore: boolean, isSystemCiFollowupComment: boolean): boolean { + return shouldIgnore && !isSystemCiFollowupComment; +} + +function isMissingCommentTrigger(hasProcessingLabel: boolean, isTriggered: boolean, isSystemCiFollowupComment: boolean): boolean { + return !hasProcessingLabel && !isTriggered && !isSystemCiFollowupComment; +} + export async function processCommentEvent(payload: IssueCommentEvent | PullRequestReviewCommentEvent, eventType: CommentEventType, correlationId: string, config: CommentEventConfig): Promise { const { redisClient } = config; const correlatedLogger = logger.withCorrelation(correlationId); @@ -596,10 +624,10 @@ export async function processCommentEvent(payload: IssueCommentEvent | PullReque const eventDetails = getCommentEventDetails(payload, eventType, repoFullName, correlatedLogger); if (!eventDetails) return { status: 'ignored', reason: 'not_pull_request_comment' }; - const { prNumber, comment } = eventDetails; + const { prNumber, comment: rawComment } = eventDetails; - const commentAuthor = comment.user.login; - const parsedCommand = parseSlashCommand(comment.body); + const commentAuthor = rawComment.user.login; + const parsedCommand = parseSlashCommand(rawComment.body); const configuredBotUsernames = new Set( [getBotUsername(), process.env.GITHUB_BOT_USERNAME, 'propr-dev[bot]'] .filter((value): value is string => typeof value === 'string' && value.length > 0) @@ -608,14 +636,25 @@ export async function processCommentEvent(payload: IssueCommentEvent | PullReque && ( configuredBotUsernames.has(commentAuthor) ); + // The marker authenticates the otherwise-filtered ProPR bot comment at the + // intake boundary. It is control metadata and must never reach the agent. + const { comment, isSystemCiFollowupComment } = prepareCiFollowupComment( + rawComment, + commentAuthor, + configuredBotUsernames, + ); const filterResult = filterCommentByAuthor(commentAuthor, comment.user.type ?? null, correlationId); - if (filterResult.shouldFilter && !isSystemUltrafixComment) return { status: 'ignored', reason: 'filtered_author' }; + if (shouldFilterSystemComment(filterResult.shouldFilter, isSystemUltrafixComment, isSystemCiFollowupComment)) { + return { status: 'ignored', reason: 'filtered_author' }; + } // Check for ignore keywords const ignoreKeywords = await loadFollowupIgnoreKeywords(); const ignoreResult = checkCommentIgnore(comment.body, ignoreKeywords, correlationId); - if (ignoreResult.shouldIgnore) return { status: 'ignored', reason: 'ignore_keyword' }; + if (shouldIgnoreSystemComment(ignoreResult.shouldIgnore, isSystemCiFollowupComment)) { + return { status: 'ignored', reason: 'ignore_keyword' }; + } // Parse slash commands (/review, /fix, /merge, /switch, /use) before generic follow-up logic if (parsedCommand) { @@ -643,7 +682,7 @@ export async function processCommentEvent(payload: IssueCommentEvent | PullReque // Check trigger: PR must have a processing label OR comment must contain trigger keyword const triggerResult = checkCommentTrigger(comment.body, correlationId); - if (!hasProcessingLabel && !triggerResult.isTriggered) { + if (isMissingCommentTrigger(hasProcessingLabel, triggerResult.isTriggered, isSystemCiFollowupComment)) { correlatedLogger.debug({ pullRequestNumber: prNumber, commentId: comment.id }, 'PR does not have processing label and comment does not contain trigger keyword, skipping'); return { status: 'ignored', reason: 'no_comment_trigger' }; } diff --git a/packages/core/src/webhook/planIssueTracking.ts b/packages/core/src/webhook/planIssueTracking.ts index 82ef44efb..2f8cbdd7f 100644 --- a/packages/core/src/webhook/planIssueTracking.ts +++ b/packages/core/src/webhook/planIssueTracking.ts @@ -14,6 +14,7 @@ import { getAuthenticatedOctokit } from '../auth/githubAuth.js'; import { loadPrLabel } from '../config/configManager.js'; import { checkAndMigrateRepositoryFromWebhook } from './planIssueTrackingHelpers.js'; import { handleMergedPRNextIssueTrigger } from './planIssueTrigger.js'; +import { notificationService } from '../services/notificationService.js'; import type { IssuesEvent, IssueCommentEvent, @@ -242,6 +243,20 @@ export async function handlePlanPRUpdate( const action = payload.action; try { + if (action === 'closed' && payload.pull_request.merged === true) { + try { + await notificationService.markPullRequestMergedAndDismissNotifications( + repository, + prNumber, + payload.pull_request.merged_at ?? undefined + ); + } catch (error) { + // Inbox lifecycle is best effort and must not prevent the plan + // issue or chained-plan merge handling below. + log.warn({ error, repository, prNumber }, 'Failed to dismiss merged PR notifications'); + } + } + await checkRenamesFromPRBody(payload, repository, prNumber, log); const prTitle = payload.pull_request.title || ''; diff --git a/packages/core/test/notificationService.test.ts b/packages/core/test/notificationService.test.ts index d354a8516..55cbba2e1 100644 --- a/packages/core/test/notificationService.test.ts +++ b/packages/core/test/notificationService.test.ts @@ -30,6 +30,8 @@ import { down as removeBadgePreference, up as addBadgePreference } from '../src/db/migrations/20260824010000_add_notification_badge_preference.js'; +import { up as addSystemFailureState } from '../src/db/migrations/20260829000000_add_notification_system_failure_state.js'; +import { up as addPullRequestState } from '../src/db/migrations/20260829010000_add_notification_pull_request_state.js'; let database: Knex; let service: NotificationService; @@ -96,6 +98,8 @@ beforeEach(async () => { await addPreferenceApis(database); await addBadgePreference(database); await addAdvertisedActions(database); + await addSystemFailureState(database); + await addPullRequestState(database); service = new NotificationService({ database, now: () => new Date(clock += 1000), @@ -352,6 +356,296 @@ describe('notification service', { concurrency: false }, () => { assert.equal(await service.dismissNotification('user-b', 'event-a'), null); }); + test('dismisses every active Inbox receipt for only the requested user', async () => { + await createEvent('event-a', '2026-08-02T07:00:00.000Z', ['user-a', 'user-b']); + await createEvent('event-b', '2026-08-02T08:00:00.000Z', ['user-a', 'user-b']); + await createEvent('event-push-only', '2026-08-02T09:00:00.000Z', [{ + userId: 'user-a', inboxEnabled: false, pushEnabled: true + }]); + await service.markNotificationRead('user-a', 'event-a'); + + assert.deepEqual(await service.dismissAllNotifications('user-a'), { unreadCount: 0 }); + assert.deepEqual(await service.dismissAllNotifications('user-a'), { unreadCount: 0 }); + assert.deepEqual((await service.listNotifications('user-a')).notifications, []); + assert.deepEqual( + (await service.listNotifications('user-a', { includeDismissed: true })) + .notifications.map(notification => notification.id), + ['event-b', 'event-a'] + ); + assert.deepEqual( + (await service.listNotifications('user-b')).notifications.map(notification => notification.id), + ['event-b', 'event-a'] + ); + const pushOnlyReceipt = await database('notification_user_states') + .where({ user_id: 'user-a', event_id: 'event-push-only' }) + .first(); + assert.equal(pushOnlyReceipt, undefined, 'push-only recipients stay outside the Inbox'); + assert.equal( + await database('notification_events').count('* as count').first() + .then(row => Number(row?.count)), + 3, + 'immutable event audit rows remain' + ); + }); + + test('dismisses all PR-related receipts without deleting audit events', async () => { + const recipients = ['user-a', 'user-b']; + await service.createNotificationEvent({ + eventId: 'pr-task-event', + deduplicationKey: 'pr-task-event-key', + kind: 'task', + target: { + type: 'task', repository: 'integry/propr', taskId: 'pr-task', prNumber: 42 + }, + title: 'Implementation completed', body: 'Implementation completed.', recipients + }); + await service.createNotificationEvent({ + eventId: 'pr-review-event', + deduplicationKey: 'pr-review-event-key', + kind: 'review', + target: { + type: 'review', repository: 'integry/propr', taskId: 'review-task', prNumber: 42 + }, + title: 'Review completed', body: 'Review completed.', recipients + }); + await service.createNotificationEvent({ + eventId: 'pr-attention-event', + deduplicationKey: 'pr-attention-event-key', + kind: 'pull_request', + target: { type: 'pull_request', repository: 'integry/propr', prNumber: 42 }, + title: 'Pull request needs attention', body: 'PR needs attention.', recipients + }); + await service.createNotificationEvent({ + eventId: 'other-pr-event', + deduplicationKey: 'other-pr-event-key', + kind: 'pull_request', + target: { type: 'pull_request', repository: 'integry/propr', prNumber: 43 }, + title: 'Other pull request', body: 'Another PR.', recipients + }); + + assert.equal(await service.dismissNotificationsForPullRequest('integry/propr', 42), 6); + assert.equal(await service.dismissNotificationsForPullRequest('integry/propr', 42), 0); + assert.equal( + await database('notification_events').count('* as count').first() + .then(row => Number(row?.count)), + 4, + 'immutable event audit rows remain', + ); + assert.deepEqual( + (await service.listNotifications('user-a')).notifications.map(item => item.id), + ['other-pr-event'] + ); + assert.equal( + (await service.listNotifications('user-a', { includeDismissed: true })) + .notifications.length, + 4 + ); + }); + + test('rolls back PR-attention creation when atomic supersession fails', async () => { + await service.createPullRequestAttentionNotificationEvent( + 'integry/propr', + 42, + { + eventId: 'first-attention-event', + deduplicationKey: 'first-attention-key', + kind: 'pull_request', + target: { type: 'pull_request', repository: 'integry/propr', prNumber: 42 }, + title: 'Pull request needs attention', + body: 'First attention card.', + occurredAt: '2026-08-02T08:00:00.000Z' + }, + ['user-a'] + ); + await database.raw(` + CREATE TRIGGER reject_attention_supersession + BEFORE UPDATE OF dismissed_at ON notification_user_states + BEGIN + SELECT RAISE(ABORT, 'forced supersession failure'); + END + `); + + await assert.rejects( + service.createPullRequestAttentionNotificationEvent( + 'integry/propr', + 42, + { + eventId: 'second-attention-event', + deduplicationKey: 'second-attention-key', + kind: 'pull_request', + target: { type: 'pull_request', repository: 'integry/propr', prNumber: 42 }, + title: 'Pull request needs attention', + body: 'Second attention card.', + occurredAt: '2026-08-02T09:00:00.000Z' + }, + ['user-a'] + ), + /forced supersession failure/ + ); + + assert.deepEqual( + await database('notification_events') + .where({ kind: 'pull_request' }) + .pluck('event_id'), + ['first-attention-event'], + 'the new audit event and receipt roll back with supersession' + ); + assert.deepEqual( + (await service.listNotifications('user-a')).notifications.map(item => item.id), + ['first-attention-event'] + ); + }); + + test('reconciles pre-state system cards during healthy and unhealthy bootstrap', async () => { + for (const component of ['redis', 'worker']) { + await service.createNotificationEvent({ + eventId: `legacy-${component}-failure`, + deduplicationKey: `legacy-${component}-failure-key`, + kind: 'system_failure', + severity: 'error', + target: { type: 'system_failure', component }, + title: 'System component unhealthy', + body: `${component} is not reporting a healthy status.`, + occurredAt: '2026-08-02T08:00:00.000Z' + }, ['user-a']); + } + assert.equal( + await database('notification_system_failure_state').count('* as count').first() + .then(row => Number(row?.count)), + 0, + 'simulates receipts created before the durable state migration was populated' + ); + + await service.reconcileSystemFailureTransition({ + component: 'redis', + status: 'connected', + healthy: true, + snapshotAt: '2026-08-02T09:00:00.000Z', + eventFor: () => { + throw new Error('healthy initialization must not create an event'); + } + }, ['user-a']); + await service.reconcileSystemFailureTransition({ + component: 'worker', + status: 'stopped', + healthy: false, + snapshotAt: '2026-08-02T09:00:00.000Z', + eventFor: (status, failureStartedAt) => ({ + eventId: 'current-worker-failure', + deduplicationKey: `current-worker:${status}:${failureStartedAt}`, + kind: 'system_failure', + severity: 'error', + target: { type: 'system_failure', component: 'worker' }, + title: 'System component unhealthy', + body: 'worker is not reporting a healthy status.', + occurredAt: failureStartedAt + }) + }, ['user-a']); + + const active = await database('notification_user_states as receipt') + .join('notification_events as event', 'event.event_id', 'receipt.event_id') + .whereNull('receipt.dismissed_at') + .select('event.event_id'); + assert.deepEqual(active, [{ event_id: 'current-worker-failure' }]); + assert.equal( + await database('notification_events') + .where({ kind: 'system_failure' }) + .count('* as count') + .first() + .then(row => Number(row?.count)), + 3, + 'legacy audit events are preserved' + ); + assert.deepEqual( + await database('notification_system_failure_state') + .select('component', 'failure_status') + .orderBy('component'), + [ + { component: 'redis', failure_status: null }, + { component: 'worker', failure_status: 'stopped' } + ] + ); + }); + + test('serializes system transitions so an older writer cannot dismiss the current failure', async () => { + const temporaryDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), 'propr-system-transition-race-') + ); + const databasePath = path.join(temporaryDirectory, 'notifications.db'); + const olderDatabase = createDatabase(databasePath); + try { + await up(olderDatabase); + await addPreferenceApis(olderDatabase); + await addBadgePreference(olderDatabase); + await addAdvertisedActions(olderDatabase); + await addSystemFailureState(olderDatabase); + const olderService = new NotificationService({ + database: olderDatabase, + now: () => new Date('2026-08-02T10:00:00.000Z'), + generateId: () => 'older-failure-event' + }); + const newerService = new NotificationService({ + database: olderDatabase, + now: () => new Date('2026-08-02T10:00:01.000Z'), + generateId: () => 'newer-failure-event' + }); + let releaseOlder: (() => void) | undefined; + const olderPaused = new Promise(resolve => { + releaseOlder = resolve; + }); + let signalOlderEvent: (() => void) | undefined; + const olderReachedEvent = new Promise(resolve => { + signalOlderEvent = resolve; + }); + const eventFor = (status: string, occurredAt: string) => ({ + deduplicationKey: `system:${status}:${occurredAt}`, + kind: 'system_failure' as const, + severity: 'error' as const, + target: { type: 'system_failure' as const, component: 'redis' }, + title: 'System component unhealthy', + body: 'redis is not reporting a healthy status.', + actions: ['dismiss' as const], + occurredAt + }); + const olderProjection = olderService.reconcileSystemFailureTransition({ + component: 'redis', + status: 'disconnected', + healthy: false, + snapshotAt: '2026-08-02T09:00:00.000Z', + eventFor: async (status, occurredAt) => { + signalOlderEvent?.(); + await olderPaused; + return eventFor(status, occurredAt); + } + }, ['user-a']); + await olderReachedEvent; + + const newerProjection = newerService.reconcileSystemFailureTransition({ + component: 'redis', + status: 'connection-error', + healthy: false, + snapshotAt: '2026-08-02T09:00:01.000Z', + eventFor + }, ['user-a']); + // The newer instance is now in flight while the older instance is + // paused inside its transaction. Releasing the older callback lets + // SQLite serialize both writes without a post-commit stale window. + releaseOlder?.(); + await Promise.all([olderProjection, newerProjection]); + + const active = await olderDatabase('notification_user_states as receipt') + .join('notification_events as event', 'event.event_id', 'receipt.event_id') + .whereNull('receipt.dismissed_at') + .select('event.deduplication_key'); + assert.deepEqual(active, [{ + deduplication_key: 'system:connection-error:2026-08-02T09:00:01.000Z' + }]); + } finally { + await olderDatabase.destroy(); + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); + } + }); + test('rejects malformed pagination inputs and clamps large valid limits', async () => { assert.equal(parseNotificationListLimit(10_000), MAX_NOTIFICATION_LIST_LIMIT); await assert.rejects( diff --git a/packages/core/test/syntheticRoutingService.test.ts b/packages/core/test/syntheticRoutingService.test.ts new file mode 100644 index 000000000..2ec547c1d --- /dev/null +++ b/packages/core/test/syntheticRoutingService.test.ts @@ -0,0 +1,346 @@ +import assert from 'node:assert/strict'; +import { after, afterEach, describe, test } from 'node:test'; +import knex, { type Knex } from 'knex'; +import type { SyntheticAgentConfig } from '@propr/shared'; +import { + SyntheticPoolExhaustedError, + SyntheticRoutingService, + type SyntheticUsageSnapshotProvider, +} from '../src/services/syntheticRoutingService.js'; +import type { + Agent, + AgentConfig, + AgentExecutionResult, + AgentTaskOptions, + AnalysisResult, + AnalyzeOptions, +} from '../src/agents/types.js'; +import { db as globalDatabase } from '../src/db/connection.js'; +import { up as createSyntheticRoutingCursors } from '../src/db/migrations/20260830000000_create_synthetic_routing_cursors.js'; + +const MEMBER_A = '11111111-1111-4111-8111-111111111111'; +const MEMBER_B = '22222222-2222-4222-8222-222222222222'; + +class FakeAgent implements Agent { + analyzeCalls: AnalyzeOptions[] = []; + taskCalls: AgentTaskOptions[] = []; + analysisResults: Array = []; + taskResults: Array = []; + + constructor(readonly config: AgentConfig) {} + + async analyze(_prompt: string, options: AnalyzeOptions = {}): Promise { + this.analyzeCalls.push(options); + const next = this.analysisResults.shift(); + if (next instanceof Error) throw next; + return next ?? { response: this.config.alias, modelUsed: options.model || '', executionTimeMs: 1, success: true }; + } + + async executeTask(options: AgentTaskOptions): Promise { + this.taskCalls.push(options); + await options.onContainerId?.(`${this.config.alias}-container-${this.taskCalls.length}`, `${this.config.alias}-run-${this.taskCalls.length}`); + const next = this.taskResults.shift(); + if (next instanceof Error) throw next; + return next ?? { success: true, logs: '', modifiedFiles: [], modelUsed: options.model || '', executionTimeMs: 1 }; + } + + async healthCheck(): Promise { return true; } +} + +function direct(alias: string, model: string): FakeAgent { + return new FakeAgent({ + id: alias, alias, type: model.startsWith('gpt') ? 'codex' : 'claude', enabled: true, + dockerImage: 'test', configPath: 'test', supportedModels: [model], defaultModel: model, + }); +} + +function config(options: { + strategy?: 'round_robin' | 'usage_based'; + priorityA?: number; + priorityB?: number; + usageA?: { sessionMaxPercent?: number; weeklyMaxPercent?: number }; + usageB?: { sessionMaxPercent?: number; weeklyMaxPercent?: number }; +} = {}): SyntheticAgentConfig { + return { + id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', alias: 'pool', enabled: true, defaultModel: 'smart', + models: [{ + id: 'smart', enabled: true, strategy: options.strategy ?? 'round_robin', + members: [ + { id: MEMBER_A, directAgentAlias: 'large', model: 'claude-opus-4-6', enabled: true, priority: options.priorityA ?? 100, usageLimits: options.usageA }, + { id: MEMBER_B, directAgentAlias: 'small', model: 'gpt-5-mini', enabled: true, priority: options.priorityB ?? 0, usageLimits: options.usageB }, + ], + }], + }; +} + +let databases: Knex[] = []; + +async function database(): Promise { + const value = knex({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + await createSyntheticRoutingCursors(value); + await value.schema.createTable('tasks', table => table.string('task_id').primary()); + await value.schema.createTable('task_history', table => { + table.increments('history_id').primary(); + table.string('task_id').notNullable(); + table.string('state').notNullable(); + table.timestamp('timestamp'); + table.text('reason'); + table.json('metadata'); + }); + databases.push(value); + return value; +} + +function service( + database: Knex, + synthetic: SyntheticAgentConfig, + agents: FakeAgent[], + usageSnapshotProvider?: SyntheticUsageSnapshotProvider, +): SyntheticRoutingService { + const byAlias = new Map(agents.map(agent => [agent.config.alias, agent])); + return new SyntheticRoutingService({ + database, + loadSyntheticConfigs: async () => [synthetic], + getDirectAgent: alias => byAlias.get(alias), + usageSnapshotProvider: usageSnapshotProvider ?? { getSnapshot: async () => null }, + }); +} + +afterEach(async () => { + await Promise.all(databases.map(value => value.destroy())); + databases = []; +}); + +after(async () => { + await globalDatabase.destroy(); +}); + +describe('SyntheticRoutingService', () => { + test('passes direct-agent requests through unchanged', async () => { + const db = await database(); + const large = direct('large', 'claude-opus-4-6'); + const router = service(db, config(), [large]); + const session = router.begin({ requestedAgentAlias: 'large', requestedModel: 'claude-opus-4-6' }); + const selection = await session.select(); + assert.equal(selection.synthetic, false); + assert.equal(selection.physicalAgent, large); + const result = await session.analyze('hello'); + assert.equal(result.response, 'large'); + assert.equal(large.analyzeCalls[0].metadata?.syntheticRouting, undefined); + }); + + test('uses only the highest eligible priority and falls back when it is context-ineligible', async () => { + const db = await database(); + const large = direct('large', 'claude-opus-4-6'); + const small = direct('small', 'gpt-5-mini'); + const router = service(db, config(), [large, small]); + + const preferred = await router.begin({ requestedAgentAlias: 'pool', requestedModel: 'smart', requiredTokens: 100_000 }).select(); + assert.equal(preferred.memberId, MEMBER_A); + + const fallbackConfig = config({ priorityA: 0, priorityB: 100 }); + const fallbackRouter = service(db, fallbackConfig, [large, small]); + const fallback = await fallbackRouter.begin({ requestedAgentAlias: 'pool', requestedModel: 'smart', requiredTokens: 300_000 }).select(); + assert.equal(fallback.memberId, MEMBER_A); + assert.match(fallback.diagnostics.find(item => item.memberId === MEMBER_B)?.reason || '', /context window/); + }); + + test('round robin cursor persists across service instances', async () => { + const db = await database(); + const agents = [direct('large', 'claude-opus-4-6'), direct('small', 'gpt-5-mini')]; + const pool = config({ priorityB: 100 }); + + const first = await service(db, pool, agents).begin({ requestedAgentAlias: 'pool', requestedModel: 'smart' }).select(); + const second = await service(db, pool, agents).begin({ requestedAgentAlias: 'pool', requestedModel: 'smart' }).select(); + const third = await service(db, pool, agents).begin({ requestedAgentAlias: 'pool', requestedModel: 'smart' }).select(); + + assert.deepEqual([first.memberId, second.memberId, third.memberId], [MEMBER_A, MEMBER_B, MEMBER_A]); + }); + + test('usage based selection rejects unknown capped aliases and picks greatest normalized headroom', async () => { + const db = await database(); + const agents = [direct('large', 'claude-opus-4-6'), direct('small', 'gpt-5-mini')]; + const pool = config({ strategy: 'usage_based', priorityB: 100, usageA: { weeklyMaxPercent: 80 }, usageB: { weeklyMaxPercent: 80 } }); + const usage: SyntheticUsageSnapshotProvider = { + getSnapshot: async alias => alias === 'large' + ? { directAgentAlias: alias, capturedAt: new Date(), weeklyPercent: 70 } + : { directAgentAlias: alias, capturedAt: new Date(), weeklyPercent: 20 }, + }; + const chosen = await service(db, pool, agents, usage).begin({ requestedAgentAlias: 'pool', requestedModel: 'smart' }).select(); + assert.equal(chosen.memberId, MEMBER_B); + + const unknown = service(db, pool, agents, { getSnapshot: async () => null }); + await assert.rejects( + () => unknown.begin({ requestedAgentAlias: 'pool', requestedModel: 'smart' }).select(), + (error: unknown) => error instanceof SyntheticPoolExhaustedError && /alias-specific usage data unavailable/.test(error.message), + ); + }); + + test('failed analysis member is attempted once and routing metadata is attached to each attempt', async () => { + const db = await database(); + const large = direct('large', 'claude-opus-4-6'); + const small = direct('small', 'gpt-5-mini'); + large.analysisResults.push({ response: '', modelUsed: 'claude-opus-4-6', executionTimeMs: 1, success: false, error: 'provider unavailable' }); + const router = service(db, config({ priorityB: 100 }), [large, small]); + + const session = router.begin({ requestedAgentAlias: 'pool', requestedModel: 'smart' }); + const result = await session.analyze('hello'); + assert.equal(result.response, 'small'); + assert.equal(large.analyzeCalls.length, 1); + assert.equal(small.analyzeCalls.length, 1); + const firstMetadata = large.analyzeCalls[0].metadata?.syntheticRouting as Record; + const secondMetadata = small.analyzeCalls[0].metadata?.syntheticRouting as Record; + assert.equal(firstMetadata.virtualAgentAlias, 'pool'); + assert.equal(firstMetadata.attemptNumber, 1); + assert.equal(secondMetadata.attemptNumber, 2); + assert.equal(firstMetadata.callId, secondMetadata.callId); + assert.deepEqual(session.routingMetadata, secondMetadata); + }); + + test('routed PR review analysis keeps routing metadata out of task lifecycle history', async () => { + const db = await database(); + await db('tasks').insert({ task_id: 'review-task' }); + const large = direct('large', 'claude-opus-4-6'); + const small = direct('small', 'gpt-5-mini'); + + const result = await service(db, config({ priorityB: 100 }), [large, small]) + .begin({ requestedAgentAlias: 'pool', requestedModel: 'smart' }) + .analyze('review this pull request', { + taskId: 'review-task', + prNumber: 1995, + executionType: 'pr-review', + }); + + assert.equal(result.success, true); + assert.equal(large.analyzeCalls[0].executionType, 'pr-review'); + assert.equal( + (large.analyzeCalls[0].metadata?.syntheticRouting as Record).physicalAgentAlias, + 'large', + ); + assert.deepEqual(await db('task_history').where({ task_id: 'review-task' }), []); + }); + + test('applies call-scoped physical eligibility to initial selection and every retry', async () => { + const db = await database(); + const large = direct('large', 'claude-opus-4-6'); + const small = direct('small', 'gpt-5-mini'); + large.analysisResults.push({ response: '', modelUsed: 'claude-opus-4-6', executionTimeMs: 1, success: false, error: 'provider unavailable' }); + const router = service(db, config({ priorityB: 100 }), [large, small]); + const session = router.begin({ + requestedAgentAlias: 'pool', + requestedModel: 'smart', + physicalAgentEligibility: agent => agent.config.alias === 'large', + }); + + const first = await session.select(); + assert.equal(first.physicalAgentAlias, 'large'); + await assert.rejects( + () => session.analyze('hello'), + (error: unknown) => error instanceof SyntheticPoolExhaustedError + && /physical agent is ineligible for this routing session/.test(error.message), + ); + assert.equal(large.analyzeCalls.length, 1); + assert.equal(small.analyzeCalls.length, 0); + }); + + test('explicit cancellation is not retried', async () => { + const db = await database(); + const large = direct('large', 'claude-opus-4-6'); + const small = direct('small', 'gpt-5-mini'); + large.analysisResults.push({ response: '', modelUsed: 'claude-opus-4-6', executionTimeMs: 1, success: false, error: 'Execution aborted by user request' }); + const result = await service(db, config({ priorityB: 100 }), [large, small]) + .begin({ requestedAgentAlias: 'pool', requestedModel: 'smart' }).analyze('hello'); + assert.equal(result.success, false); + assert.equal(small.analyzeCalls.length, 0); + }); + + test('transport abort fails over to the next eligible member', async () => { + const db = await database(); + const large = direct('large', 'claude-opus-4-6'); + const small = direct('small', 'gpt-5-mini'); + large.analysisResults.push({ response: '', modelUsed: 'claude-opus-4-6', executionTimeMs: 1, success: false, error: 'upstream stream aborted; connection canceled while reading response' }); + + const result = await service(db, config({ priorityB: 100 }), [large, small]) + .begin({ requestedAgentAlias: 'pool', requestedModel: 'smart' }).analyze('hello'); + + assert.equal(result.success, true); + assert.equal(result.response, 'small'); + assert.equal(large.analyzeCalls.length, 1); + assert.equal(small.analyzeCalls.length, 1); + }); + + test('thrown transport AbortError fails over to the next eligible member', async () => { + const db = await database(); + const large = direct('large', 'claude-opus-4-6'); + const small = direct('small', 'gpt-5-mini'); + const transportAbort = new Error('socket closed while reading response'); + transportAbort.name = 'AbortError'; + large.analysisResults.push(transportAbort); + + const result = await service(db, config({ priorityB: 100 }), [large, small]) + .begin({ requestedAgentAlias: 'pool', requestedModel: 'smart' }).analyze('hello'); + + assert.equal(result.success, true); + assert.equal(result.response, 'small'); + assert.equal(large.analyzeCalls.length, 1); + assert.equal(small.analyzeCalls.length, 1); + }); + + test('explicit task cancellation error is not retried when error text is absent', async () => { + const db = await database(); + const large = direct('large', 'claude-opus-4-6'); + const small = direct('small', 'gpt-5-mini'); + const taskCancellation = new Error(); + taskCancellation.name = 'ExecutionAbortedError'; + large.taskResults.push(taskCancellation); + + await assert.rejects( + () => service(db, config({ priorityB: 100 }), [large, small]) + .begin({ requestedAgentAlias: 'pool', requestedModel: 'smart' }) + .executeTask({ + worktreePath: '/tmp/worktree', + issueRef: { number: 1, repoOwner: 'integry', repoName: 'propr' }, + prompt: 'implement it', + model: 'smart', + githubToken: 'test-token', + }), + (error: unknown) => error === taskCancellation, + ); + assert.equal(large.taskCalls.length, 1); + assert.equal(small.taskCalls.length, 0); + }); + + test('implementation failover preserves virtual task identity and records each physical container', async () => { + const db = await database(); + await db('tasks').insert({ task_id: 'task-1' }); + const large = direct('large', 'claude-opus-4-6'); + const small = direct('small', 'gpt-5-mini'); + large.taskResults.push({ success: false, error: 'runtime failed', logs: '', modifiedFiles: [], modelUsed: 'claude-opus-4-6', executionTimeMs: 1 }); + const router = service(db, config({ priorityB: 100 }), [large, small]); + + const result = await router.begin({ requestedAgentAlias: 'pool', requestedModel: 'smart' }).executeTask({ + worktreePath: '/tmp/worktree', + issueRef: { number: 1, repoOwner: 'integry', repoName: 'propr' }, + prompt: 'implement it', + model: 'smart', + githubToken: 'test-token', + branchName: 'virtual-branch', + taskId: 'task-1', + }); + + assert.equal(result.success, true); + assert.equal(large.taskCalls.length, 1); + assert.equal(small.taskCalls.length, 1); + assert.equal(large.taskCalls[0].branchName, 'virtual-branch'); + assert.equal(small.taskCalls[0].branchName, 'virtual-branch'); + const history = await db('task_history').where({ task_id: 'task-1' }).orderBy('history_id'); + assert.equal(history.length, 2); + const first = JSON.parse(history[0].metadata); + const second = JSON.parse(history[1].metadata); + assert.equal(first.syntheticRouting.physicalAgentAlias, 'large'); + assert.equal(first.containerId, 'large-container-1'); + assert.equal(second.syntheticRouting.physicalAgentAlias, 'small'); + assert.equal(second.containerId, 'small-container-1'); + assert.equal(first.syntheticRouting.callId, second.syntheticRouting.callId); + }); +}); diff --git a/packages/shared/package.json b/packages/shared/package.json index 574c5b692..bf0ed1768 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -9,6 +9,9 @@ "build": "tsc", "typecheck": "tsc --noEmit" }, + "dependencies": { + "zod": "^4.4.3" + }, "devDependencies": { "typescript": "^5.9.3" } diff --git a/packages/shared/src/connectDiscovery.ts b/packages/shared/src/connectDiscovery.ts index 9132eaab0..30498e241 100644 --- a/packages/shared/src/connectDiscovery.ts +++ b/packages/shared/src/connectDiscovery.ts @@ -124,9 +124,10 @@ export function parseProprDesktopDiscovery(value: unknown): ProprDesktopDiscover } /** - * Parse discovery from its bounded wire representation. JSON.parse accepts - * duplicate object members, so discovery performs a structural pass before - * the schema parser. This keeps every client on the same fail-closed contract. + * Parse discovery from its bounded wire representation. JSON.parse silently + * accepts duplicate object members, so discovery uses this small structural + * pass before the schema parser. Keeping it here makes CLI, client and desktop + * consumers agree on duplicate, size and schema rejection. */ export function parseProprDesktopDiscoveryJson(contents: string): ProprDesktopDiscovery | null { if (typeof contents !== 'string' diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ffff1dc46..0d15fde8d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -94,6 +94,26 @@ export { type InstanceCatalogResponse, } from './instanceCatalog.js'; +export { + SYNTHETIC_SELECTION_STRATEGIES, + syntheticUsageLimitsSchema, + syntheticModelMemberSchema, + syntheticModelConfigSchema, + syntheticAgentConfigSchema, + syntheticAgentConfigsSchema, + parseSyntheticAgentConfigs, + validateSyntheticAgentReferences, + validateExecutableSyntheticDefault, + findSyntheticReferencesToDirectAgent, + type SyntheticSelectionStrategy, + type SyntheticUsageLimits, + type SyntheticModelMember, + type SyntheticModelConfig, + type SyntheticAgentConfig, + type SyntheticDirectAgentReference, + type SyntheticReferenceValidationResult, +} from './syntheticAgents.js'; + // Export user whitelist helpers export { getGithubUserWhitelist, diff --git a/packages/shared/src/instanceCatalog.ts b/packages/shared/src/instanceCatalog.ts index 34594b7c9..713587f78 100644 --- a/packages/shared/src/instanceCatalog.ts +++ b/packages/shared/src/instanceCatalog.ts @@ -1,4 +1,8 @@ export interface InstanceCatalogAgent { + /** Stable configuration identity. Omitted by older servers. */ + id?: string; + /** Omitted by older servers; consumers should treat omission as direct. */ + kind?: 'direct' | 'synthetic'; alias: string; /** Always true: the operational catalog omits disabled entries. */ enabled: boolean; diff --git a/packages/shared/src/modelDefinitions.ts b/packages/shared/src/modelDefinitions.ts index f8fe3fb97..f73097040 100644 --- a/packages/shared/src/modelDefinitions.ts +++ b/packages/shared/src/modelDefinitions.ts @@ -161,7 +161,7 @@ export const AGENT_DEFAULTS: Record m.id), defaultAlias: 'codex', npmPackage: '@openai/codex', - defaultCliVersion: '0.146.0' + defaultCliVersion: '0.151.0' }, antigravity: { dockerImage: 'propr/agent:latest', diff --git a/packages/shared/src/syntheticAgents.ts b/packages/shared/src/syntheticAgents.ts new file mode 100644 index 000000000..9a95562c8 --- /dev/null +++ b/packages/shared/src/syntheticAgents.ts @@ -0,0 +1,224 @@ +import { z } from 'zod'; + +export const SYNTHETIC_SELECTION_STRATEGIES = [ + 'round_robin', + 'usage_based', +] as const; + +export type SyntheticSelectionStrategy = + (typeof SYNTHETIC_SELECTION_STRATEGIES)[number]; + +export const syntheticUsageLimitsSchema = z.object({ + sessionMaxPercent: z.number().finite().min(1).max(100).optional(), + weeklyMaxPercent: z.number().finite().min(1).max(100).optional(), +}).strict(); + +export const syntheticModelMemberSchema = z.object({ + id: z.string().uuid(), + directAgentAlias: z.string().trim().min(1), + model: z.string().trim().min(1), + enabled: z.boolean().default(true), + priority: z.number().int().min(0).max(100).default(100), + usageLimits: syntheticUsageLimitsSchema.optional(), +}).strict(); + +export const syntheticModelConfigSchema = z.object({ + id: z.string().regex( + /^[a-z0-9][a-z0-9-]{0,62}$/, + 'Synthetic model IDs must use lowercase letters, numbers, and hyphens', + ), + displayName: z.string().trim().min(1).max(100).optional(), + enabled: z.boolean().default(true), + strategy: z.enum(SYNTHETIC_SELECTION_STRATEGIES).default('round_robin'), + members: z.array(syntheticModelMemberSchema).min(1), +}).strict().superRefine((model, context) => { + const memberIds = new Set(); + const physicalPairs = new Set(); + + model.members.forEach((member, index) => { + if (memberIds.has(member.id)) { + context.addIssue({ + code: 'custom', + path: ['members', index, 'id'], + message: `Duplicate synthetic member ID '${member.id}'`, + }); + } + memberIds.add(member.id); + + const pair = JSON.stringify([member.directAgentAlias, member.model]); + if (physicalPairs.has(pair)) { + context.addIssue({ + code: 'custom', + path: ['members', index], + message: `Duplicate direct member '${member.directAgentAlias}:${member.model}'`, + }); + } + physicalPairs.add(pair); + }); +}); + +export const syntheticAgentConfigSchema = z.object({ + id: z.string().uuid(), + alias: z.string().regex( + /^[a-z0-9][a-z0-9-]{0,62}$/, + 'Synthetic aliases must use lowercase letters, numbers, and hyphens', + ), + enabled: z.boolean().default(true), + defaultModel: z.string().min(1), + models: z.array(syntheticModelConfigSchema).min(1), +}).strict().superRefine((agent, context) => { + const modelIds = new Set(); + agent.models.forEach((model, index) => { + if (modelIds.has(model.id)) { + context.addIssue({ + code: 'custom', + path: ['models', index, 'id'], + message: `Duplicate synthetic model ID '${model.id}'`, + }); + } + modelIds.add(model.id); + }); + + if (!agent.models.some(model => model.id === agent.defaultModel && model.enabled)) { + context.addIssue({ + code: 'custom', + path: ['defaultModel'], + message: `Default model '${agent.defaultModel}' is missing or disabled`, + }); + } +}); + +export const syntheticAgentConfigsSchema = z.array(syntheticAgentConfigSchema) + .superRefine((agents, context) => { + const aliases = new Set(); + const agentIds = new Set(); + agents.forEach((agent, index) => { + if (agentIds.has(agent.id)) { + context.addIssue({ + code: 'custom', + path: [index, 'id'], + message: `Duplicate synthetic agent ID '${agent.id}'`, + }); + } + agentIds.add(agent.id); + + if (aliases.has(agent.alias)) { + context.addIssue({ + code: 'custom', + path: [index, 'alias'], + message: `Duplicate synthetic alias '${agent.alias}'`, + }); + } + aliases.add(agent.alias); + }); + }); + +export type SyntheticUsageLimits = z.infer; +export type SyntheticModelMember = z.infer; +export type SyntheticModelConfig = z.infer; +export type SyntheticAgentConfig = z.infer; + +export interface SyntheticDirectAgentReference { + id: string; + alias: string; + enabled: boolean; + supportedModels: string[]; +} + +export interface SyntheticReferenceValidationResult { + errors: string[]; + warnings: string[]; +} + +export function parseSyntheticAgentConfigs(value: unknown): SyntheticAgentConfig[] { + return syntheticAgentConfigsSchema.parse(value); +} + +export function validateSyntheticAgentReferences( + syntheticAgents: SyntheticAgentConfig[], + directAgents: SyntheticDirectAgentReference[], +): SyntheticReferenceValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + const directByAlias = new Map(directAgents.map(agent => [agent.alias, agent])); + const directIds = new Set(directAgents.map(agent => agent.id)); + + for (const syntheticAgent of syntheticAgents) { + if (directIds.has(syntheticAgent.id)) { + errors.push(`Synthetic agent ID '${syntheticAgent.id}' conflicts with a direct agent ID`); + } + if (directByAlias.has(syntheticAgent.alias)) { + errors.push(`Synthetic alias '${syntheticAgent.alias}' conflicts with a direct agent alias`); + } + + for (const syntheticModel of syntheticAgent.models) { + let enabledMembers = 0; + for (const member of syntheticModel.members) { + const directAgent = directByAlias.get(member.directAgentAlias); + if (!directAgent) { + errors.push( + `${syntheticAgent.alias}:${syntheticModel.id} references unknown direct agent '${member.directAgentAlias}'`, + ); + continue; + } + if (!directAgent.supportedModels.includes(member.model)) { + errors.push( + `${syntheticAgent.alias}:${syntheticModel.id} references unsupported model ` + + `'${member.directAgentAlias}:${member.model}'`, + ); + continue; + } + if (member.enabled && directAgent.enabled) enabledMembers += 1; + } + + if (syntheticModel.enabled && enabledMembers === 0) { + warnings.push(`${syntheticAgent.alias}:${syntheticModel.id} has no enabled direct members`); + } + } + } + + return { errors, warnings }; +} + +/** Returns an actionable error when a configured synthetic default cannot execute. */ +export function validateExecutableSyntheticDefault( + defaultAlias: string, + syntheticAgents: SyntheticAgentConfig[], + directAgents: SyntheticDirectAgentReference[], + requireSynthetic = false, +): string | undefined { + const syntheticAgent = syntheticAgents.find(agent => agent.alias === defaultAlias); + if (!syntheticAgent) { + return requireSynthetic + ? `Configured synthetic default '${defaultAlias}' no longer exists. Select another default agent first.` + : undefined; + } + if (!syntheticAgent.enabled) { + return `Configured synthetic default '${defaultAlias}' is disabled. Select another default agent first.`; + } + const defaultModel = syntheticAgent.models.find(model => model.id === syntheticAgent.defaultModel); + if (!defaultModel?.enabled) { + return `Configured synthetic default '${defaultAlias}' has no enabled default model. Select another default agent first.`; + } + const directByAlias = new Map(directAgents.map(agent => [agent.alias, agent])); + const executable = defaultModel.members.some(member => { + const directAgent = directByAlias.get(member.directAgentAlias); + return member.enabled + && directAgent?.enabled + && directAgent.supportedModels.includes(member.model); + }); + return executable + ? undefined + : `Configured synthetic default '${defaultAlias}' has no enabled member backed by an enabled direct agent supporting its physical model. Select another default agent first.`; +} + +export function findSyntheticReferencesToDirectAgent( + syntheticAgents: SyntheticAgentConfig[], + directAgentAlias: string, +): string[] { + return syntheticAgents.flatMap(agent => agent.models.flatMap(model => + model.members.some(member => member.directAgentAlias === directAgentAlias) + ? [`${agent.alias}:${model.id}`] + : [], + )); +} diff --git a/propr-ui/src/api/agentChatApi.ts b/propr-ui/src/api/agentChatApi.ts index 54a523e0b..b4433e92e 100644 --- a/propr-ui/src/api/agentChatApi.ts +++ b/propr-ui/src/api/agentChatApi.ts @@ -3,6 +3,8 @@ import { API_BASE_URL, apiFetch, handleApiResponse } from './apiClient'; export interface ChatQuery { agentId: string; + /** Stable synthetic configuration identity, present only for pool choices. */ + syntheticConfigId?: string; model?: string; } @@ -13,6 +15,12 @@ export interface ChatResult { response?: string; error?: string; durationMs: number; + syntheticConfigId?: string; + virtualAgentAlias?: string; + virtualModel?: string; + physicalAgentAlias?: string; + physicalModel?: string; + attemptNumber?: number; } export const chatWithAgents = async ( diff --git a/propr-ui/src/api/configApi.ts b/propr-ui/src/api/configApi.ts index 34e2ace1c..95e14f20b 100644 --- a/propr-ui/src/api/configApi.ts +++ b/propr-ui/src/api/configApi.ts @@ -5,6 +5,7 @@ import type { RepoConfigResponse, SystemSettings, } from './proprTypes'; +import type { SyntheticAgentConfig } from '@propr/shared'; import { API_BASE_URL, apiFetch, handleApiResponse } from './apiClient'; async function getJson(path: string): Promise { @@ -95,6 +96,23 @@ export interface SaveAgentsResponse { export const getAgents = (): Promise<{ agents: AgentConfig[] }> => getJson('/api/config/agents'); export const saveAgents = (agents: AgentConfig[]): Promise => postJson('/api/config/agents', { agents }); + +export interface SyntheticAgentsResponse { + synthetic_agents: SyntheticAgentConfig[]; +} + +export interface SaveSyntheticAgentsResponse extends SyntheticAgentsResponse { + success: boolean; + warnings?: string[]; +} + +export const getSyntheticAgents = (): Promise => + getJson('/api/config/synthetic-agents'); + +export const saveSyntheticAgents = ( + syntheticAgents: SyntheticAgentConfig[], +): Promise => + postJson('/api/config/synthetic-agents', { synthetic_agents: syntheticAgents }); export const getOpenCodeModels = (agentId?: string): Promise<{ models: string[] }> => { const params = agentId ? `?agentId=${encodeURIComponent(agentId)}` : ''; return getJson(`/api/agents/opencode/models${params}`); diff --git a/propr-ui/src/api/notificationApi.test.ts b/propr-ui/src/api/notificationApi.test.ts index 5d3f36827..7bd8997c8 100644 --- a/propr-ui/src/api/notificationApi.test.ts +++ b/propr-ui/src/api/notificationApi.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; -import { dismissNotification, markNotificationRead } from './notificationApi'; +import { dismissAllNotifications, dismissNotification, markNotificationRead } from './notificationApi'; const event = { id: 'event:token-refresh', @@ -55,4 +55,22 @@ describe('notification mutation API', () => { expect(init?.body).toBeUndefined(); } }); + + test('replays clear-all after token refresh and validates the unread count', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(tokenRefreshed()) + .mockResolvedValueOnce(new Response(JSON.stringify({ unreadCount: 0 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })); + + await expect(dismissAllNotifications()).resolves.toEqual({ unreadCount: 0 }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + for (const [url, init] of fetchMock.mock.calls) { + expect(String(url)).toContain('/api/notifications/dismiss-all'); + expect(init).toMatchObject({ method: 'POST', credentials: 'include' }); + expect(init?.body).toBeUndefined(); + } + }); }); diff --git a/propr-ui/src/api/notificationApi.ts b/propr-ui/src/api/notificationApi.ts index 859a08fd2..a86ea2849 100644 --- a/propr-ui/src/api/notificationApi.ts +++ b/propr-ui/src/api/notificationApi.ts @@ -79,6 +79,12 @@ export function dismissNotification(id: string): Promise { + return requestJson('/dismiss-all', notificationUnreadCountResponseSchema, { + method: 'POST', + }); +} + export function getNotificationPreferences(): Promise { return requestJson('/preferences', notificationPreferencesResponseSchema); } diff --git a/propr-ui/src/api/proprApi.instanceCatalog.test.ts b/propr-ui/src/api/proprApi.instanceCatalog.test.ts new file mode 100644 index 000000000..0cdef3426 --- /dev/null +++ b/propr-ui/src/api/proprApi.instanceCatalog.test.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getInstanceCatalog } from './proprApi'; + +describe('getInstanceCatalog', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('loads synthetic agents from the extended instance catalog endpoint', async () => { + const catalog = { + agents: [ + { + id: 'balanced-pool-id', + kind: 'synthetic' as const, + alias: 'balanced-pool', + enabled: true, + supportedModels: ['balanced'], + defaultModel: 'balanced', + }, + ], + repositories: [], + }; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response( + JSON.stringify(catalog), + { status: 200, headers: { 'Content-Type': 'application/json' } } + )); + + const response = await getInstanceCatalog(); + + expect(response).toEqual(catalog); + expect(fetchSpy).toHaveBeenCalledWith('/api/instance/catalog', { credentials: 'include' }); + expect(response.agents).toContainEqual(expect.objectContaining({ kind: 'synthetic' })); + }); +}); diff --git a/propr-ui/src/api/proprApi.ts b/propr-ui/src/api/proprApi.ts index d58d5ae1b..30cb14a53 100644 --- a/propr-ui/src/api/proprApi.ts +++ b/propr-ui/src/api/proprApi.ts @@ -33,7 +33,7 @@ export const getSystemStatus = async (): Promise => { const workers: { id: number; status: string }[] = []; for (let i = 0; i < (data.workerCount || 0); i++) workers.push({ id: i + 1, status: 'active' }); const mapAuthStatus = (status?: string) => status === 'connected' ? 'Authenticated' : 'Failed'; - const mapAgentStatus = (status?: string) => status === 'connected' ? 'Ready' : 'Failed'; + const mapAgentStatus = (status?: string) => status === 'connected' ? 'Ready' : status === 'degraded' ? 'Degraded' : 'Failed'; const mapIndexingStatus = (status?: string) => { switch (status) { case 'active': @@ -201,7 +201,7 @@ export const getTaskLiveDetails = async (taskId: string): Promise => { }; export const getInstanceCatalog = async (): Promise => { - const response = await apiFetch(`${API_BASE_URL}/api/catalog`, { credentials: 'include' }); + const response = await apiFetch(`${API_BASE_URL}/api/instance/catalog`, { credentials: 'include' }); await handleApiResponse(response); return response.json(); }; diff --git a/propr-ui/src/api/proprTypes.ts b/propr-ui/src/api/proprTypes.ts index c16086331..07cbfb8f6 100644 --- a/propr-ui/src/api/proprTypes.ts +++ b/propr-ui/src/api/proprTypes.ts @@ -118,6 +118,8 @@ export interface MonitoredRepo { id: string; name: string; enabled: boolean; + /** Whether failed CI triggers an automatic follow-up. Missing legacy values are off. */ + autoFollowupOnFailedCi?: boolean; alias?: string; baseBranch?: string; starred?: boolean; diff --git a/propr-ui/src/components/AddRepositoryForm.tsx b/propr-ui/src/components/AddRepositoryForm.tsx index fb6c2049c..e797f18ce 100644 --- a/propr-ui/src/components/AddRepositoryForm.tsx +++ b/propr-ui/src/components/AddRepositoryForm.tsx @@ -5,22 +5,28 @@ interface AddRepositoryFormProps { newRepo: string; newAlias: string; newBaseBranch: string; + autoFollowupOnFailedCi: boolean; availableRepos: string[]; onRepoChange: (value: string) => void; onAliasChange: (value: string) => void; onBaseBranchChange: (value: string) => void; + onAutoFollowupOnFailedCiChange: (value: boolean) => void; onAdd: () => void; + isReadOnly?: boolean; } export const AddRepositoryForm: React.FC = ({ newRepo, newAlias, newBaseBranch, + autoFollowupOnFailedCi, availableRepos, onRepoChange, onAliasChange, onBaseBranchChange, + onAutoFollowupOnFailedCiChange, onAdd, + isReadOnly = false, }) => { return (
@@ -34,6 +40,7 @@ export const AddRepositoryForm: React.FC = ({ onChange={(e) => onRepoChange(e.target.value)} placeholder="owner/repo" className="w-full px-3 py-2 bg-white text-gray-900 border border-gray-300 rounded-md font-mono text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500" + disabled={isReadOnly} /> {availableRepos.map(repo =>
@@ -55,15 +63,16 @@ export const AddRepositoryForm: React.FC = ({ value={newBaseBranch} onChange={onBaseBranchChange} placeholder="Select branch..." + disabled={isReadOnly} />
+

You can add the same repository multiple times with different base branches to monitor multiple branches.

diff --git a/propr-ui/src/components/AddRepositoryModal.tsx b/propr-ui/src/components/AddRepositoryModal.tsx index aa1bcf3c4..e6d906282 100644 --- a/propr-ui/src/components/AddRepositoryModal.tsx +++ b/propr-ui/src/components/AddRepositoryModal.tsx @@ -7,10 +7,12 @@ interface AddRepositoryModalProps { newRepo: string; newAlias: string; newBaseBranch: string; + autoFollowupOnFailedCi: boolean; availableRepos: string[]; onRepoChange: (value: string) => void; onAliasChange: (value: string) => void; onBaseBranchChange: (value: string) => void; + onAutoFollowupOnFailedCiChange: (value: boolean) => void; onAdd: () => void; onClose: () => void; isReadOnly?: boolean; @@ -21,10 +23,12 @@ export const AddRepositoryModal: React.FC = ({ newRepo, newAlias, newBaseBranch, + autoFollowupOnFailedCi, availableRepos, onRepoChange, onAliasChange, onBaseBranchChange, + onAutoFollowupOnFailedCiChange, onAdd, onClose, isReadOnly = false, @@ -107,6 +111,22 @@ export const AddRepositoryModal: React.FC = ({ You can add the same repository multiple times with different base branches.

+ + {/* Modal Footer */} diff --git a/propr-ui/src/components/AgentChat/ChatPanel.tsx b/propr-ui/src/components/AgentChat/ChatPanel.tsx index a92b23495..bd629805e 100644 --- a/propr-ui/src/components/AgentChat/ChatPanel.tsx +++ b/propr-ui/src/components/AgentChat/ChatPanel.tsx @@ -3,18 +3,24 @@ import { AgentConfig, chatWithAgents, ChatResult, ChatQuery } from '../../api/pr import { MODEL_INFO_MAP, AgentType } from '../../config/modelDefinitions'; import { ProviderLogo } from '../ui/ProviderLogo'; import { Bot, User, Send } from 'lucide-react'; +import { Layers3 } from 'lucide-react'; +import type { SyntheticAgentConfig } from '@propr/shared'; // Enhanced badge colors for selected state - more visually prominent -const selectedBadgeColors: Record = { +type AgentVisualType = AgentType | 'synthetic'; + +const selectedBadgeColors: Record = { claude: 'bg-orange-500 text-white border-orange-600 shadow-md ring-2 ring-orange-300', codex: 'bg-green-500 text-white border-green-600 shadow-md ring-2 ring-green-300', antigravity: 'bg-violet-500 text-white border-violet-600 shadow-md ring-2 ring-violet-300', opencode: 'bg-cyan-500 text-white border-cyan-600 shadow-md ring-2 ring-cyan-300', - vibe: 'bg-pink-500 text-white border-pink-600 shadow-md ring-2 ring-pink-300' + vibe: 'bg-pink-500 text-white border-pink-600 shadow-md ring-2 ring-pink-300', + synthetic: 'bg-slate-600 text-white border-slate-700 shadow-md ring-2 ring-slate-300' }; interface ChatPanelProps { agents: AgentConfig[]; + syntheticAgents?: SyntheticAgentConfig[]; selectedModels: AgentModelSelection[]; onSelectedModelsChange: (selectedModels: AgentModelSelection[]) => void; disabled?: boolean; @@ -36,7 +42,8 @@ interface Message { interface AgentModelOption { agentId: string; agentAlias: string; - agentType: AgentType; + agentType: AgentVisualType; + syntheticConfigId?: string; modelId: string; modelName: string; } @@ -56,6 +63,7 @@ const haveSameSelections = ( const ChatPanel: React.FC = ({ agents, + syntheticAgents = [], selectedModels, onSelectedModelsChange, disabled = false @@ -80,8 +88,20 @@ const ChatPanel: React.FC = ({ }); }); }); + syntheticAgents.filter(pool => pool.enabled).forEach(pool => { + pool.models.filter(model => model.enabled).forEach(model => { + options.push({ + agentId: pool.id, + syntheticConfigId: pool.id, + agentAlias: pool.alias, + agentType: 'synthetic', + modelId: model.id, + modelName: model.displayName || model.id, + }); + }); + }); return options; - }, [agents]); + }, [agents, syntheticAgents]); // Keep selections limited to combinations exposed by the Playground. If an // agent is disabled or removed, fall back to the first available option. @@ -124,10 +144,14 @@ const ChatPanel: React.FC = ({ ).join('\n'); // Build queries with agent+model combinations - const queries: ChatQuery[] = selectedModels.map(selection => ({ - agentId: selection.agentId, - model: selection.modelId - })); + const queries: ChatQuery[] = selectedModels.map(selection => { + const option = agentModelOptions.find(candidate => isSameAgentModel(candidate, selection)); + return { + agentId: selection.agentId, + ...(option?.syntheticConfigId ? { syntheticConfigId: option.syntheticConfigId } : {}), + model: selection.modelId, + }; + }); const { results } = await chatWithAgents(queries, userMsg.content!, context); @@ -209,7 +233,9 @@ const ChatPanel: React.FC = ({ : 'bg-white/70 border-gray-200 text-gray-400 hover:bg-white hover:border-gray-300 hover:text-gray-600' }`} > - + {option.syntheticConfigId + ?