From 603ef9c688a65390f2dff55646223dff3b75193f 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:24:07 +0000 Subject: [PATCH 1/5] fix(ai): Resolve issue #2083 - Prove native macOS/Linux desktop artifact install Implemented by ProPR AI using gpt-5.6-sol model. Implementation completed successfully. --- .github/workflows/desktop-release-guard.yml | 21 +- apps/desktop/README.md | 75 +++ apps/desktop/forge.config.ts | 2 + .../test-native-artifact-lifecycle.mjs | 614 ++++++++++++++++++ .../test-native-artifact-lifecycle.test.mjs | 49 ++ apps/desktop/src/deep-link-delivery.test.ts | 34 + apps/desktop/src/deep-link-delivery.ts | 35 +- apps/desktop/src/main.ts | 187 +++++- apps/desktop/src/release-workflow.test.ts | 31 + apps/desktop/src/smoke-test-evidence.test.ts | 14 + apps/desktop/src/smoke-test-evidence.ts | 29 +- 11 files changed, 1080 insertions(+), 11 deletions(-) create mode 100644 apps/desktop/scripts/test-native-artifact-lifecycle.mjs create mode 100644 apps/desktop/scripts/test-native-artifact-lifecycle.test.mjs diff --git a/.github/workflows/desktop-release-guard.yml b/.github/workflows/desktop-release-guard.yml index d121602e4..f6e4cd9a9 100644 --- a/.github/workflows/desktop-release-guard.yml +++ b/.github/workflows/desktop-release-guard.yml @@ -149,7 +149,7 @@ jobs: if: matrix.platform == 'linux' run: | sudo apt-get update - sudo apt-get install --yes cpio fakeroot rpm zip + sudo apt-get install --yes cpio dbus-x11 desktop-file-utils fakeroot libglib2.0-bin libsecret-1-0 rpm unzip xdg-utils xvfb zip - name: Package desktop app from clean checkout shell: bash @@ -264,6 +264,25 @@ jobs: --make-directory apps/desktop/out/make \ --output "desktop-release-${{ matrix.platform }}-${{ matrix.arch }}" + - name: Exercise staged native install, deep-link, relaunch, and removal lifecycle + if: matrix.platform == 'linux' || matrix.platform == 'darwin' + shell: bash + run: | + if [ "${{ matrix.platform }}" = linux ]; then + dbus-run-session -- xvfb-run --auto-servernum \ + node apps/desktop/scripts/test-native-artifact-lifecycle.mjs \ + --version "$PROPR_DESKTOP_VERSION" \ + --platform linux \ + --arch "${{ matrix.arch }}" \ + --artifact-directory "desktop-release-${{ matrix.platform }}-${{ matrix.arch }}" + else + node apps/desktop/scripts/test-native-artifact-lifecycle.mjs \ + --version "$PROPR_DESKTOP_VERSION" \ + --platform darwin \ + --arch "${{ matrix.arch }}" \ + --artifact-directory "desktop-release-${{ matrix.platform }}-${{ matrix.arch }}" + fi + - name: Upload unsigned validation target uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: diff --git a/apps/desktop/README.md b/apps/desktop/README.md index c33d0488f..9b243d39d 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -104,6 +104,81 @@ PROPR_DESKTOP_ENABLE_RPM=1 \ npm run make -w @propr/desktop -- --arch="$(node -p process.arch)" ``` +### Unsigned internal-RC install and removal (macOS/Linux) + +Use the artifact whose final `x64` or `arm64` suffix matches the machine. These commands are for internal release +candidates only. They do not assert signing or notarization, do not change Gatekeeper policy, and do not use `xattr`, +`spctl --add`, or another quarantine bypass. + +On Debian/Ubuntu, exercise the DEB and remove it with the native package manager: + +```sh +ARCH=x64 # use arm64 on an ARM64 Linux machine +VERSION=0.8.15 +sudo apt install "./ProPR-Desktop-${VERSION}-linux-${ARCH}.deb" +propr-desktop +xdg-open 'propr://connect?api=http%3A%2F%2Flocalhost%3A4000' +xdg-open 'propr://connect?api=https%3A%2F%2Ft-your-tunnel.propr.dev' +sudo apt remove propr-desktop +``` + +On Fedora/RHEL-family systems, use the RPM. The ZIP is the non-package-manager alternative on either family: + +```sh +ARCH=x64 # use arm64 on an ARM64 Linux machine +VERSION=0.8.15 +sudo rpm --install "ProPR-Desktop-${VERSION}-linux-${ARCH}.rpm" +propr-desktop +sudo rpm --erase propr-desktop + +install_root="$(mktemp -d)" +unzip "ProPR-Desktop-${VERSION}-linux-${ARCH}.zip" -d "$install_root" +"$install_root/propr-desktop-linux-${ARCH}/propr-desktop" +rm -r "$install_root" +``` + +On either Intel (`x64`) or Apple Silicon (`arm64`) macOS, the DMG flow mounts and copies the app; the ZIP flow uses +the system archive tool. Quit ProPR Desktop before removal: + +```sh +ARCH=arm64 # use x64 on an Intel Mac +VERSION=0.8.15 +mount_point="$(mktemp -d)" +hdiutil attach -readonly -nobrowse -mountpoint "$mount_point" \ + "ProPR-Desktop-${VERSION}-macos-${ARCH}.dmg" +ditto "$mount_point/propr-desktop.app" '/Applications/propr-desktop.app' +hdiutil detach "$mount_point" +rmdir "$mount_point" +open '/Applications/propr-desktop.app' +open 'propr://connect?api=http%3A%2F%2Flocalhost%3A4000' +open 'propr://connect?api=https%3A%2F%2Ft-your-tunnel.propr.dev' +osascript -e 'tell application id "dev.propr.desktop" to quit' +rm -r '/Applications/propr-desktop.app' + +install_root="$(mktemp -d)" +ditto -x -k "ProPR-Desktop-${VERSION}-macos-${ARCH}.zip" "$install_root" +open "$install_root/propr-desktop.app" +osascript -e 'tell application id "dev.propr.desktop" to quit' +rm -r "$install_root" +``` + +The pull-request native gate runs on `ubuntu-24.04`/`ubuntu-24.04-arm` and +`macos-15-intel`/`macos-15`. It consumes the canonical staged bytes: DEB/RPM are extracted with `dpkg-deb`/`rpm2cpio` +and ZIP with the platform archive tool; DMG is mounted read-only and copied with `ditto`. Every format gets a first +launch, clean shutdown, preserved-state relaunch, and owned-root removal. The gate rechecks the executable architecture, +identity/version, launcher or bundle, safe paths/symlinks, 0700/0600 profile authority, unchanged artifact bytes, and +absence of default-profile leakage. A fixed non-secret custody probe must either round-trip through OS encryption or be +refused with no `basic_text`/plaintext fallback; macOS additionally requires the OS-protected Keychain backend. +Evidence files contain fixed event names only—never endpoints, paths, credentials, +or process output. + +Linux DEB/RPM protocol evidence uses an isolated XDG MIME database and a CI-relocated copy of the package's real +desktop launcher, then dispatches with `gio`; ZIP has no registered launcher, so its single-instance dispatch evidence +is direct and is reported as that limitation. macOS registers the copied bundle with LaunchServices and dispatches with +`open -b`. LaunchServices' own database writes are OS-managed evidence, not application profile writes. These checks do +not claim end-user Gatekeeper approval for unsigned builds, signing, notarization, or behavior on a desktop session that +the hosted runner cannot provide. + ### CI preflight, signing, and notarization configuration Repository-ruleset inspection uses a dedicated GitHub App installed only on this repository. Configure the App with diff --git a/apps/desktop/forge.config.ts b/apps/desktop/forge.config.ts index 16a29fb1c..405223f4f 100644 --- a/apps/desktop/forge.config.ts +++ b/apps/desktop/forge.config.ts @@ -162,6 +162,7 @@ const config: ForgeConfig = { productName: 'ProPR Desktop', version: releaseVersion, bin: DESKTOP_EXECUTABLE_NAME, + mimeType: ['x-scheme-handler/propr'], }, })] : []), @@ -172,6 +173,7 @@ const config: ForgeConfig = { productName: 'ProPR Desktop', version: releaseVersion, bin: DESKTOP_EXECUTABLE_NAME, + mimeType: ['x-scheme-handler/propr'], }, })] : []), diff --git a/apps/desktop/scripts/test-native-artifact-lifecycle.mjs b/apps/desktop/scripts/test-native-artifact-lifecycle.mjs new file mode 100644 index 000000000..90192a2c1 --- /dev/null +++ b/apps/desktop/scripts/test-native-artifact-lifecycle.mjs @@ -0,0 +1,614 @@ +import { spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { constants as fsConstants } from 'node:fs'; +import { + chmod, + copyFile, + lstat, + mkdir, + mkdtemp, + open, + readFile, + readdir, + realpath, + rm, + stat, + writeFile, +} from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { arch as hostArch, platform as hostPlatform, tmpdir } from 'node:os'; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { once } from 'node:events'; +import { fileURLToPath } from 'node:url'; +import { + createHeldDmgArtifact, + inspectArtifactArchitecture, + inspectExecutableBytes, +} from './release-architecture.mjs'; +import { + createPrivateSmokeProfile, + createSmokeChildEnvironment, + removePrivateSmokeProfile, +} from './packaged-smoke-support.mjs'; + +const EXECUTABLE = 'propr-desktop'; +const APP_ID = 'dev.propr.desktop'; +const PROCESS_TIMEOUT_MS = 45_000; +const COMMAND_TIMEOUT_MS = 10 * 60_000; +const OUTPUT_CAP = 64 * 1024; +const COLD_MANUAL = 'propr://connect?api=http%3A%2F%2Flocalhost%3A44111'; +const COLD_TUNNEL = 'propr://connect?api=https%3A%2F%2Ft-native-relaunch.propr.dev'; +const WARM_MANUAL = 'propr://connect?api=http%3A%2F%2F127.0.0.1%3A44112'; +const WARM_TUNNEL = 'propr://connect?api=https%3A%2F%2Ft-native-evidence.propr.dev'; +const WARM_OPEN = 'propr://open?path=%2Ftasks%3Fstatus%3Dopen'; +const REQUIRED_FIRST_EVENTS = [ + 'desktop.smoke.authorized', + 'desktop.native.identity_verified', + 'desktop.deeplink.cold_manual_once', + 'desktop.native.secure_storage_enforced', + 'desktop.native.profile_fresh', + 'desktop.renderer.ready', + 'desktop.deeplink.warm_manual_once', + 'desktop.deeplink.warm_tunnel_once', + 'desktop.deeplink.warm_open_once', + 'desktop.deeplink.rejected_malformed', + 'desktop.deeplink.rejected_oversized', + 'desktop.deeplink.rejected_unsafe_scheme', + 'desktop.deeplink.confirmation_required', + 'desktop.app.shutdown', +]; +const REQUIRED_RELAUNCH_EVENTS = [ + 'desktop.smoke.authorized', + 'desktop.native.identity_verified', + 'desktop.deeplink.cold_tunnel_once', + 'desktop.native.profile_preserved', + 'desktop.deeplink.confirmation_required', + 'desktop.renderer.ready', + 'desktop.app.shutdown', +]; + +export const parseArguments = args => { + const values = new Map(); + for (let index = 0; index < args.length; index += 2) { + const name = args[index]; + const value = args[index + 1]; + if (!name?.startsWith('--') || !value || values.has(name)) { + throw new Error('Native artifact lifecycle arguments are missing, duplicated, or malformed'); + } + values.set(name, value); + } + const platform = values.get('--platform'); + const arch = values.get('--arch'); + const version = values.get('--version'); + const artifactDirectory = values.get('--artifact-directory'); + if (!['linux', 'darwin'].includes(platform) || !['x64', 'arm64'].includes(arch) + || !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version ?? '') + || !artifactDirectory || values.size !== 4) { + throw new Error('Native artifact lifecycle target is invalid'); + } + return { platform, arch, version, artifactDirectory: resolve(artifactDirectory) }; +}; + +const appendBounded = (current, chunk) => { + const next = Buffer.concat([current, Buffer.from(chunk)]); + return next.length <= OUTPUT_CAP ? next : next.subarray(next.length - OUTPUT_CAP); +}; + +const run = (file, args, { cwd, env, timeout = COMMAND_TIMEOUT_MS, input } = {}) => new Promise((resolveRun, reject) => { + const child = spawn(file, args, { + cwd, + env, + shell: false, + stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'], + }); + let stdout = Buffer.alloc(0); + let stderr = Buffer.alloc(0); + child.stdout.on('data', chunk => { stdout = appendBounded(stdout, chunk); }); + child.stderr.on('data', chunk => { stderr = appendBounded(stderr, chunk); }); + if (input !== undefined) child.stdin.end(input); + const timer = setTimeout(() => child.kill('SIGKILL'), timeout); + child.once('error', error => { + clearTimeout(timer); + reject(error); + }); + child.once('close', (code, signal) => { + clearTimeout(timer); + if (code !== 0) { + reject(new Error(`${basename(file)} failed with code ${code ?? 'null'} signal ${signal ?? 'none'}`)); + return; + } + resolveRun({ stdout, stderr }); + }); +}); + +const digest = async path => createHash('sha256').update(await readFile(path)).digest('hex'); + +const inspectStagedArtifact = async ({ artifact, kind, target, workRoot }) => { + if (kind !== 'dmg') { + return inspectArtifactArchitecture({ path: artifact, kind, platform: target.platform, arch: target.arch }); + } + const privatePath = join(workRoot, 'held-artifact.dmg'); + await copyFile(artifact, privatePath, fsConstants.COPYFILE_EXCL); + await chmod(privatePath, 0o600); + const handle = await open(privatePath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW); + try { + const heldArtifact = createHeldDmgArtifact(handle, 'staged native lifecycle DMG', privatePath); + return await inspectArtifactArchitecture({ + heldArtifact, + kind, + platform: target.platform, + arch: target.arch, + }); + } finally { + await handle.close(); + await rm(privatePath, { force: true }); + } +}; + +const canonicalArtifact = ({ directory, platform, arch, version, kind }) => join( + directory, + `ProPR-Desktop-${version}-${platform === 'darwin' ? 'macos' : 'linux'}-${arch}.${kind}`, +); + +export const assertArtifactSet = async target => { + const expectedKinds = target.platform === 'linux' ? ['deb', 'rpm', 'zip'] : ['dmg', 'zip']; + const entries = await readdir(target.artifactDirectory, { withFileTypes: true }); + for (const kind of expectedKinds) { + const path = canonicalArtifact({ directory: target.artifactDirectory, ...target, kind }); + const entry = entries.find(candidate => candidate.name === basename(path)); + if (!entry?.isFile() || entry.isSymbolicLink()) { + throw new Error(`Native lifecycle requires exactly the canonical staged ${kind} artifact`); + } + } + const unexpected = entries.filter(entry => { + if (entry.name === 'release-fragment.json') return false; + return !expectedKinds.some(kind => entry.name === basename(canonicalArtifact({ + directory: target.artifactDirectory, ...target, kind, + }))); + }); + if (unexpected.length) throw new Error('Native lifecycle artifact directory contains an unexpected or duplicate identity'); + return expectedKinds; +}; + +const assertSafeExtractedTree = async root => { + const canonicalRoot = await realpath(root); + const visit = async directory => { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isSymbolicLink()) { + const target = await realpath(path); + const fromRoot = relative(canonicalRoot, target); + if (!fromRoot || fromRoot === '..' || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) { + throw new Error('Native artifact contains a symlink escaping its install root'); + } + } else if (entry.isDirectory()) { + await visit(path); + } else if (!entry.isFile()) { + throw new Error('Native artifact contains an unsupported filesystem entry'); + } + } + }; + await visit(root); +}; + +const extractRpm = async (artifact, root) => { + await new Promise((resolveExtraction, reject) => { + const converter = spawn('/usr/bin/rpm2cpio', [artifact], { shell: false, stdio: ['ignore', 'pipe', 'pipe'] }); + const extractor = spawn( + '/usr/bin/cpio', + ['--extract', '--make-directories', '--no-absolute-filenames', '--quiet'], + { cwd: root, shell: false, stdio: ['pipe', 'ignore', 'pipe'] }, + ); + converter.stdout.pipe(extractor.stdin); + let failure; + let diagnostics = Buffer.alloc(0); + converter.stderr.on('data', chunk => { diagnostics = appendBounded(diagnostics, chunk); }); + extractor.stderr.on('data', chunk => { diagnostics = appendBounded(diagnostics, chunk); }); + converter.once('error', error => { failure = error; extractor.kill('SIGKILL'); }); + extractor.once('error', error => { failure = error; converter.kill('SIGKILL'); }); + converter.once('close', code => { + if (code !== 0 && !failure) { + failure = new Error(`rpm2cpio failed with code ${code ?? 'null'}`); + extractor.kill('SIGKILL'); + } + }); + const timer = setTimeout(() => { + failure = new Error('RPM extraction deadline expired'); + converter.kill('SIGKILL'); + extractor.kill('SIGKILL'); + }, COMMAND_TIMEOUT_MS); + extractor.once('close', code => { + clearTimeout(timer); + if (failure) reject(failure); + else if (code !== 0) reject(new Error(`cpio failed with code ${code ?? 'null'}`)); + else if (diagnostics.length !== 0) reject(new Error('RPM extraction emitted unexpected diagnostics')); + else resolveExtraction(); + }); + }); +}; + +const locateApplication = async ({ platform, arch, kind, installRoot }) => { + if (platform === 'linux') { + const packagePayload = join(installRoot, 'usr', 'lib', EXECUTABLE); + const zipPayload = join(installRoot, `propr-desktop-linux-${arch}`); + const applicationRoot = kind === 'zip' ? zipPayload : packagePayload; + return { + applicationRoot, + executable: join(applicationRoot, EXECUTABLE), + desktopFile: kind === 'zip' ? null : join(installRoot, 'usr', 'share', 'applications', `${EXECUTABLE}.desktop`), + }; + } + const candidates = (await readdir(installRoot, { withFileTypes: true })) + .filter(entry => entry.isDirectory() && entry.name.endsWith('.app')); + if (candidates.length !== 1) throw new Error('Native macOS artifact has a missing or duplicate application identity'); + const applicationRoot = join(installRoot, candidates[0].name); + return { + applicationRoot, + executable: join(applicationRoot, 'Contents', 'MacOS', EXECUTABLE), + desktopFile: null, + }; +}; + +const extractArtifact = async ({ artifact, kind, target, installRoot, mountRoot }) => { + if (kind === 'deb') { + await run('/usr/bin/dpkg-deb', ['--extract', artifact, installRoot]); + } else if (kind === 'rpm') { + await extractRpm(artifact, installRoot); + } else if (kind === 'zip' && target.platform === 'linux') { + await run('/usr/bin/unzip', ['-q', artifact, '-d', installRoot]); + } else if (kind === 'zip') { + await run('/usr/bin/ditto', ['-x', '-k', artifact, installRoot]); + } else { + await run('/usr/bin/hdiutil', ['attach', '-readonly', '-nobrowse', '-mountpoint', mountRoot, artifact]); + const applications = (await readdir(mountRoot, { withFileTypes: true })) + .filter(entry => entry.isDirectory() && entry.name.endsWith('.app')); + if (applications.length !== 1) throw new Error('Mounted DMG has a missing or duplicate application identity'); + await run('/usr/bin/ditto', [join(mountRoot, applications[0].name), join(installRoot, applications[0].name)]); + } +}; + +const validateIdentity = async ({ target, kind, application }) => { + const handle = await open(application.executable, 'r'); + const bytes = Buffer.alloc(4096); + let bytesRead; + try { + ({ bytesRead } = await handle.read(bytes, 0, bytes.length, 0)); + } finally { + await handle.close(); + } + const executable = inspectExecutableBytes(bytes.subarray(0, bytesRead)); + const expectedFormat = target.platform === 'linux' ? 'elf' : 'mach-o'; + if (executable.format !== expectedFormat || executable.architectures.length !== 1 + || executable.architectures[0] !== target.arch) { + throw new Error('Extracted native artifact executable architecture mismatch'); + } + const executableStats = await lstat(application.executable); + if (!executableStats.isFile() || executableStats.isSymbolicLink() || (executableStats.mode & 0o111) === 0) { + throw new Error('Extracted native artifact executable identity is invalid'); + } + if (target.platform === 'linux') { + if (kind !== 'zip') { + const desktop = await readFile(application.desktopFile, 'utf8'); + if (!/^Name=ProPR Desktop$/m.test(desktop) || !/^Exec=propr-desktop(?:\s+%U)?$/m.test(desktop) + || !/^MimeType=.*x-scheme-handler\/propr;.*$/m.test(desktop)) { + throw new Error('Linux package launcher identity or protocol declaration is invalid'); + } + } + return; + } + const plist = join(application.applicationRoot, 'Contents', 'Info.plist'); + const readPlist = async key => (await run('/usr/bin/plutil', ['-extract', key, 'raw', '-o', '-', plist])).stdout.toString().trim(); + if (await readPlist('CFBundleIdentifier') !== APP_ID + || await readPlist('CFBundleShortVersionString') !== target.version + || await readPlist('CFBundleExecutable') !== EXECUTABLE + || await readPlist('CFBundleURLTypes.0.CFBundleURLSchemes.0') !== 'propr') { + throw new Error('macOS application identity, version, or protocol declaration is invalid'); + } +}; + +const waitForEvents = async (path, events, child, timeout = PROCESS_TIMEOUT_MS) => { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + if (child.exitCode !== null) throw new Error('Native application exited before producing required evidence'); + try { + const records = (await readFile(path, 'utf8')).trim().split('\n').filter(Boolean).map(line => JSON.parse(line)); + const names = records.map(record => record.event); + if (records.some(record => Object.keys(record).length !== 1 || typeof record.event !== 'string')) { + throw new Error('Native application emitted secret-capable evidence fields'); + } + if (events.every(event => names.includes(event))) return; + } catch (error) { + if (error?.code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error; + } + await new Promise(resolveWait => setTimeout(resolveWait, 50)); + } + throw new Error('Native application evidence deadline expired'); +}; + +const signalApplicationGroup = (child, signal) => { + if (!child.pid) return; + try { + process.kill(-child.pid, signal); + } catch (error) { + if (error?.code !== 'ESRCH') throw error; + } +}; + +const assertApplicationGroupGone = async child => { + if (!child.pid) return; + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + try { + process.kill(-child.pid, 0); + } catch (error) { + if (error?.code === 'ESRCH') return; + throw error; + } + await new Promise(resolveWait => setTimeout(resolveWait, 50)); + } + signalApplicationGroup(child, 'SIGKILL'); + throw new Error('Native application left a process in its owned process group'); +}; + +const waitForExit = (child, timeout = PROCESS_TIMEOUT_MS) => new Promise((resolveExit, reject) => { + const complete = (code, signal) => { + void assertApplicationGroupGone(child).then(() => { + if (code === 0) resolveExit(); + else reject(new Error(`Native application exited with code ${code ?? 'null'} signal ${signal ?? 'none'}`)); + }, reject); + }; + if (child.exitCode !== null) { + complete(child.exitCode, child.signalCode); + return; + } + const timer = setTimeout(() => { + signalApplicationGroup(child, 'SIGKILL'); + reject(new Error('Native application shutdown deadline expired')); + }, timeout); + child.once('close', (code, signal) => { + clearTimeout(timer); + complete(code, signal); + }); +}); + +const startApplication = (application, args, env, cwd) => spawn(application.executable, args, { + cwd, + env, + detached: true, + shell: false, + stdio: ['ignore', 'ignore', 'ignore'], +}); + +const dispatchDirect = async (application, userData, link, env) => { + const child = startApplication(application, [`--user-data-dir=${userData}`, link], env, dirname(application.applicationRoot)); + await waitForExit(child, 15_000); +}; + +const linuxProtocolDispatch = async ({ application, profile, link, env }) => { + if (!application.desktopFile) { + await dispatchDirect(application, profile.userData, link, env); + return 'direct-second-instance; ZIP has no OS launcher registration'; + } + const applications = join(profile.xdgData, 'applications'); + await mkdir(applications, { recursive: true, mode: 0o700 }); + const registered = join(applications, `${EXECUTABLE}.desktop`); + const source = await readFile(application.desktopFile, 'utf8'); + const relocated = source.replace(/^Exec=.*$/m, `Exec=${application.executable} --user-data-dir=${profile.userData} %U`); + if (relocated === source) throw new Error('Linux launcher relocation did not replace exactly one Exec declaration'); + await writeFile(registered, relocated, { mode: 0o600 }); + await run('/usr/bin/update-desktop-database', [applications], { env }); + await run('/usr/bin/xdg-mime', ['default', `${EXECUTABLE}.desktop`, 'x-scheme-handler/propr'], { env }); + const query = await run('/usr/bin/xdg-mime', ['query', 'default', 'x-scheme-handler/propr'], { env }); + if (query.stdout.toString().trim() !== `${EXECUTABLE}.desktop`) { + throw new Error('Linux native protocol registration query did not resolve the installed launcher'); + } + await run('/usr/bin/gio', ['open', link], { env, timeout: 15_000 }); + return 'xdg-mime-registration+gio-dispatch (CI-relocated package launcher)'; +}; + +const macProtocolDispatch = async ({ application, link, env }) => { + const launchServices = '/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister'; + await run(launchServices, ['-f', application.applicationRoot], { env }); + await run('/usr/bin/open', ['-b', APP_ID, link], { env, timeout: 15_000 }); + return 'LaunchServices-registration+open-bundle-dispatch'; +}; + +const assertProfileAuthority = async profile => { + const desktop = join(profile.userData, 'desktop'); + const state = join(desktop, 'profiles.json'); + const [rootStats, desktopStats, stateStats] = await Promise.all([lstat(profile.root), lstat(desktop), lstat(state)]); + if ((rootStats.mode & 0o777) !== 0o700 || (desktopStats.mode & 0o777) !== 0o700 + || (stateStats.mode & 0o777) !== 0o600 || stateStats.isSymbolicLink()) { + throw new Error('Native profile state did not retain 0700/0600 authority'); + } + const contents = await readFile(state, 'utf8'); + if (/credential|token|password|secret/i.test(contents)) { + throw new Error('Native non-secret profile state contains a secret-bearing field'); + } +}; + +const createProfileApi = async () => { + const server = createServer((request, response) => { + const allowed = request.method === 'GET' + && ['/api/compatibility', '/api/desktop/discovery'].includes(request.url ?? '') + && request.headers.origin === 'propr-app://renderer'; + response.writeHead(allowed ? 200 : 403, { + 'Access-Control-Allow-Credentials': 'true', + 'Access-Control-Allow-Origin': 'propr-app://renderer', + 'Content-Type': 'application/json', + }); + response.end(request.url === '/api/desktop/discovery' + ? '{"product":"ProPR","desktopAuthentication":{"protocolVersion":1}}' + : '{"profileEndpoint":true}'); + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Native profile API did not bind safely'); + return { server, url: `http://127.0.0.1:${address.port}` }; +}; + +const closeServer = async server => { + if (!server.listening) return; + server.closeAllConnections(); + await new Promise((resolveClose, rejectClose) => server.close(error => error ? rejectClose(error) : resolveClose())); +}; + +const defaultUserDataCandidates = target => { + const home = process.env.HOME; + if (!home || !isAbsolute(home)) throw new Error('Native lifecycle runner home is invalid'); + const applicationNames = [EXECUTABLE, 'ProPR Desktop']; + return target.platform === 'darwin' + ? applicationNames.flatMap(name => [ + join(home, 'Library', 'Application Support', name), + join(home, 'Library', 'Logs', name), + ]) + : applicationNames.flatMap(name => [join(home, '.config', name), join(home, '.cache', name)]); +}; + +const assertDefaultUserDataUntouched = async target => { + for (const path of defaultUserDataCandidates(target)) { + try { + await lstat(path); + throw new Error('Native lifecycle wrote outside the isolated user-data root'); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + } +}; + +const lifecycleForArtifact = async ({ target, kind, artifact, report }) => { + const workRoot = await mkdtemp(join(tmpdir(), `propr-native-${kind}-`)); + await chmod(workRoot, 0o700); + const installRoot = join(workRoot, 'install'); + const mountRoot = join(workRoot, 'mount'); + await mkdir(installRoot, { mode: 0o700 }); + await mkdir(mountRoot, { mode: 0o700 }); + const beforeDigest = await digest(artifact); + const profile = await createPrivateSmokeProfile(workRoot); + const profileApi = await createProfileApi(); + let application; + let mounted = false; + try { + await assertDefaultUserDataUntouched(target); + await inspectStagedArtifact({ artifact, kind, target, workRoot }); + await extractArtifact({ artifact, kind, target, installRoot, mountRoot }); + mounted = kind === 'dmg'; + application = await locateApplication({ ...target, kind, installRoot }); + await assertSafeExtractedTree(installRoot); + await validateIdentity({ target, kind, application }); + if (target.platform === 'linux') { + const sandbox = join(application.applicationRoot, 'chrome-sandbox'); + await run('/usr/bin/sudo', ['/usr/bin/chown', 'root:root', sandbox]); + await run('/usr/bin/sudo', ['/usr/bin/chmod', '4755', sandbox]); + } + + const baseEnvironment = await createSmokeChildEnvironment({ + profile, + profileApiUrl: profileApi.url, + }); + const firstEnvironment = Object.freeze({ + ...baseEnvironment, + PROPR_DESKTOP_NATIVE_ARTIFACT_PHASE: 'first', + PROPR_DESKTOP_NATIVE_EXPECTED_ARCH: target.arch, + PROPR_DESKTOP_NATIVE_EXPECTED_PLATFORM: target.platform, + PROPR_DESKTOP_NATIVE_EXPECTED_VERSION: target.version, + }); + const dispatchEnvironment = { ...baseEnvironment }; + delete dispatchEnvironment.PROPR_DESKTOP_SMOKE_TEST; + delete dispatchEnvironment.PROPR_DESKTOP_SMOKE_PROFILE_API_URL; + + const first = startApplication(application, [ + '--propr-smoke-test', + `--user-data-dir=${profile.userData}`, + COLD_MANUAL, + ], firstEnvironment, workRoot); + const firstEvidence = join(profile.userData, 'application.smoke-evidence.first.jsonl'); + await waitForEvents(firstEvidence, ['desktop.renderer.ready', 'desktop.deeplink.cold_manual_once'], first); + await dispatchDirect(application, profile.userData, WARM_MANUAL, dispatchEnvironment); + const protocol = target.platform === 'linux' + ? await linuxProtocolDispatch({ application, profile, link: WARM_TUNNEL, env: dispatchEnvironment }) + : await macProtocolDispatch({ application, link: WARM_TUNNEL, env: dispatchEnvironment }); + await dispatchDirect(application, profile.userData, WARM_OPEN, dispatchEnvironment); + await dispatchDirect(application, profile.userData, 'native-evidence-malformed', dispatchEnvironment); + await dispatchDirect(application, profile.userData, 'https://native-evidence.invalid/unsafe', dispatchEnvironment); + await dispatchDirect( + application, + profile.userData, + `propr://connect?api=https%3A%2F%2Ft-native-evidence.propr.dev%2F${'a'.repeat(2_100)}`, + dispatchEnvironment, + ); + await waitForExit(first); + await waitForEvents(firstEvidence, REQUIRED_FIRST_EVENTS, { exitCode: null }); + await assertProfileAuthority(profile); + + const relaunchEnvironment = Object.freeze({ + ...baseEnvironment, + PROPR_DESKTOP_NATIVE_ARTIFACT_PHASE: 'relaunch', + PROPR_DESKTOP_NATIVE_EXPECTED_ARCH: target.arch, + PROPR_DESKTOP_NATIVE_EXPECTED_PLATFORM: target.platform, + PROPR_DESKTOP_NATIVE_EXPECTED_VERSION: target.version, + }); + const relaunch = startApplication(application, [ + '--propr-smoke-test', + `--user-data-dir=${profile.userData}`, + COLD_TUNNEL, + ], relaunchEnvironment, workRoot); + await waitForExit(relaunch); + await waitForEvents( + join(profile.userData, 'application.smoke-evidence.relaunch.jsonl'), + REQUIRED_RELAUNCH_EVENTS, + { exitCode: null }, + ); + await assertProfileAuthority(profile); + if (await digest(artifact) !== beforeDigest) throw new Error('Native lifecycle mutated the staged artifact bytes'); + await assertDefaultUserDataUntouched(target); + report.push({ kind, protocol, lifecycle: 'extract-or-mount-copy/launch/shutdown/relaunch/remove' }); + } finally { + await closeServer(profileApi.server).catch(() => undefined); + if (target.platform === 'darwin' && application) { + const launchServices = '/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister'; + await run(launchServices, ['-u', application.applicationRoot]).catch(() => undefined); + } + if (mounted) await run('/usr/bin/hdiutil', ['detach', mountRoot]).catch(() => undefined); + if (target.platform === 'linux' && application) { + await run('/usr/bin/sudo', ['/bin/rm', '-f', join(application.applicationRoot, 'chrome-sandbox')]).catch(() => undefined); + } + await removePrivateSmokeProfile(profile).catch(() => undefined); + await rm(installRoot, { recursive: true, force: true }); + await rm(mountRoot, { recursive: true, force: true }); + await rm(workRoot, { recursive: true, force: true }); + for (const path of [installRoot, profile.root]) { + try { + await stat(path); + throw new Error('Native uninstall/remove left an owned root behind'); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + } + } +}; + +export const runNativeArtifactLifecycle = async target => { + if (hostPlatform() !== target.platform || hostArch() !== target.arch) { + throw new Error(`Native lifecycle requires ${target.platform}-${target.arch}, got ${hostPlatform()}-${hostArch()}`); + } + const kinds = await assertArtifactSet(target); + const report = []; + for (const kind of kinds) { + const artifact = canonicalArtifact({ directory: target.artifactDirectory, ...target, kind }); + await lifecycleForArtifact({ target, kind, artifact, report }); + } + console.log(JSON.stringify({ + schemaVersion: 1, + target: `${target.platform}-${target.arch}`, + evidence: report, + limitations: target.platform === 'linux' + ? 'ZIP has no OS launcher; its warm dispatch is direct. Package launchers use an isolated CI relocation.' + : 'Unsigned internal-RC evidence uses local LaunchServices only; signing, notarization, and Gatekeeper assessment are not claimed.', + })); +}; + +const invokedDirectly = process.argv[1] + && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); +if (invokedDirectly) await runNativeArtifactLifecycle(parseArguments(process.argv.slice(2))); diff --git a/apps/desktop/scripts/test-native-artifact-lifecycle.test.mjs b/apps/desktop/scripts/test-native-artifact-lifecycle.test.mjs new file mode 100644 index 000000000..636538bf4 --- /dev/null +++ b/apps/desktop/scripts/test-native-artifact-lifecycle.test.mjs @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, test } from 'node:test'; +import { assertArtifactSet, parseArguments } from './test-native-artifact-lifecycle.mjs'; + +describe('native staged artifact lifecycle authority', () => { + test('accepts only the exact four native target coordinates', () => { + assert.deepEqual(parseArguments([ + '--version', '1.2.3', + '--platform', 'linux', + '--arch', 'arm64', + '--artifact-directory', 'artifacts', + ]), { + version: '1.2.3', + platform: 'linux', + arch: 'arm64', + artifactDirectory: join(process.cwd(), 'artifacts'), + }); + for (const args of [ + ['--version', '1.2.3', '--platform', 'win32', '--arch', 'x64', '--artifact-directory', 'artifacts'], + ['--version', '1.2.3', '--platform', 'darwin', '--arch', 'ia32', '--artifact-directory', 'artifacts'], + ['--version', '1.2.3-beta', '--platform', 'darwin', '--arch', 'arm64', '--artifact-directory', 'artifacts'], + ['--version', '1.2.3', '--version', '1.2.4', '--platform', 'linux', '--arch', 'x64'], + ]) assert.throws(() => parseArguments(args), /invalid|missing|duplicated|malformed/); + }); + + test('fails closed for a missing kind, foreign file, or symlinked canonical artifact', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-native-artifact-set-')); + const target = { platform: 'linux', arch: 'x64', version: '1.2.3', artifactDirectory: directory }; + const names = ['deb', 'rpm', 'zip'].map(kind => `ProPR-Desktop-1.2.3-linux-x64.${kind}`); + try { + await Promise.all(names.map(name => writeFile(join(directory, name), name))); + assert.deepEqual(await assertArtifactSet(target), ['deb', 'rpm', 'zip']); + + await writeFile(join(directory, 'foreign.zip'), 'foreign'); + await assert.rejects(assertArtifactSet(target), /unexpected or duplicate identity/); + await rm(join(directory, 'foreign.zip')); + + await rm(join(directory, names[0])); + await assert.rejects(assertArtifactSet(target), /canonical staged deb/); + await symlink(join(directory, names[1]), join(directory, names[0])); + await assert.rejects(assertArtifactSet(target), /canonical staged deb/); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/desktop/src/deep-link-delivery.test.ts b/apps/desktop/src/deep-link-delivery.test.ts index 099fc4755..9c2f81a67 100644 --- a/apps/desktop/src/deep-link-delivery.test.ts +++ b/apps/desktop/src/deep-link-delivery.test.ts @@ -60,4 +60,38 @@ describe('desktop deep-link delivery', () => { assert.equal(sent.filter(({ value }) => value === connectUrl).length, 1); }); + + it('coalesces duplicate OS notifications without swallowing a later intentional activation', () => { + const sent: Array<{ channel: string; value: string }> = []; + const delivered: string[] = []; + let now = 1_000; + const link = 'propr://open?path=%2Ftasks'; + const delivery = new DeepLinkDelivery( + 'desktop:deep-link', + [], + value => delivered.push(value), + () => now, + 1_000, + ); + delivery.setWindow(createWindow(sent)); + + assert.equal(delivery.deliver(link), true); + assert.equal(delivery.deliver(link), false); + now += 1_001; + assert.equal(delivery.deliver(link), true); + + assert.deepEqual(sent.map(item => item.value), [link, link]); + assert.deepEqual(delivered, [link, link]); + }); + + it('deduplicates a cold link reported through both argv and open-url', () => { + const sent: Array<{ channel: string; value: string }> = []; + const link = 'propr://connect?api=https%3A%2F%2Ft-native-evidence.propr.dev'; + const delivery = new DeepLinkDelivery('desktop:deep-link', [link], undefined, () => 10); + + assert.equal(delivery.deliver(link), false); + delivery.setWindow(createWindow(sent)); + + assert.deepEqual(sent, [{ channel: 'desktop:deep-link', value: link }]); + }); }); diff --git a/apps/desktop/src/deep-link-delivery.ts b/apps/desktop/src/deep-link-delivery.ts index aaf9ead26..a34f91d13 100644 --- a/apps/desktop/src/deep-link-delivery.ts +++ b/apps/desktop/src/deep-link-delivery.ts @@ -9,18 +9,38 @@ export interface DeepLinkWindow { /** Coordinates protocol delivery across the window creation/load boundary. */ export class DeepLinkDelivery { private window: TWindow | null = null; + private readonly recentlyAccepted = new Map(); constructor( private readonly channel: string, private readonly pending: string[] = [], - ) {} + private readonly delivered: (value: string) => void = () => undefined, + private readonly now: () => number = Date.now, + private readonly duplicateWindowMs = 1_000, + ) { + if (!Number.isFinite(duplicateWindowMs) || duplicateWindowMs < 0) { + throw new Error('Desktop deep-link duplicate window must be non-negative'); + } + const uniquePending = [...new Set(pending)]; + pending.splice(0, pending.length, ...uniquePending); + const acceptedAt = this.now(); + uniquePending.forEach(value => this.recentlyAccepted.set(value, acceptedAt)); + } - deliver(value: string): void { + deliver(value: string): boolean { + const acceptedAt = this.now(); + const previous = this.recentlyAccepted.get(value); + if (previous !== undefined && acceptedAt - previous <= this.duplicateWindowMs) return false; + this.recentlyAccepted.set(value, acceptedAt); + for (const [candidate, time] of this.recentlyAccepted) { + if (acceptedAt - time > this.duplicateWindowMs) this.recentlyAccepted.delete(candidate); + } if (!this.window || this.window.isDestroyed() || this.window.webContents.isLoading()) { this.pending.push(value); - return; + return true; } - this.window.webContents.send(this.channel, value); + this.send(this.window, value); + return true; } didFinishLoad(window: TWindow): void { @@ -39,6 +59,11 @@ export class DeepLinkDelivery { private flush(window: TWindow): void { if (window.isDestroyed() || window.webContents.isLoading()) return; const linksToDeliver = this.pending.splice(0); - linksToDeliver.forEach(value => window.webContents.send(this.channel, value)); + linksToDeliver.forEach(value => this.send(window, value)); + } + + private send(window: TWindow, value: string): void { + window.webContents.send(this.channel, value); + this.delivered(value); } } diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 910c57b46..036706663 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -35,10 +35,17 @@ const PACKAGED_RENDERER_SCHEME = 'propr-app'; const PACKAGED_RENDERER_HOST = 'renderer'; const PACKAGED_LAYOUT_READY_EVENT = 'desktop.renderer.layout.ready'; const PACKAGED_REDUCED_NATIVE_WINDOW_READY_EVENT = 'desktop.native.reduced_window.ready'; +const NATIVE_COLD_MANUAL_LINK = 'propr://connect?api=http%3A%2F%2Flocalhost%3A44111'; +const NATIVE_COLD_TUNNEL_LINK = 'propr://connect?api=https%3A%2F%2Ft-native-relaunch.propr.dev'; +const NATIVE_WARM_MANUAL_LINK = 'propr://connect?api=http%3A%2F%2F127.0.0.1%3A44112'; +const NATIVE_WARM_TUNNEL_LINK = 'propr://connect?api=https%3A%2F%2Ft-native-evidence.propr.dev'; +const NATIVE_WARM_OPEN_LINK = 'propr://open?path=%2Ftasks%3Fstatus%3Dopen'; +type NativeSmokePhase = 'first' | 'relaunch'; const packagedRendererRoot = join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}`); const packagedRendererUrl = `${DESKTOP_RENDERER_ORIGIN}/renderer.html`; let packagedSmokeUserDataDirectory: string | null = null; let packagedSmokeEvidence: ReturnType = null; +let nativeSmokePhase: NativeSmokePhase | undefined; try { packagedSmokeUserDataDirectory = authorizePackagedSmokeTest({ argv: process.argv, @@ -48,12 +55,21 @@ try { platform: process.platform, }); if (packagedSmokeUserDataDirectory) { + const requestedNativePhase = process.env.PROPR_DESKTOP_NATIVE_ARTIFACT_PHASE; + if (requestedNativePhase !== undefined) { + if (requestedNativePhase !== 'first' && requestedNativePhase !== 'relaunch') { + throw new Error('Packaged desktop native artifact phase is invalid'); + } + nativeSmokePhase = requestedNativePhase; + } const smokeDirectoryStats = lstatSync(packagedSmokeUserDataDirectory); if (!smokeDirectoryStats.isDirectory() || smokeDirectoryStats.isSymbolicLink()) { throw new Error('Packaged desktop smoke --user-data-dir must be an existing non-link directory'); } app.setPath('userData', packagedSmokeUserDataDirectory); - packagedSmokeEvidence = createPackagedSmokeEvidenceSink(packagedSmokeUserDataDirectory); + packagedSmokeEvidence = nativeSmokePhase + ? createPackagedSmokeEvidenceSink(packagedSmokeUserDataDirectory, nativeSmokePhase) + : createPackagedSmokeEvidenceSink(packagedSmokeUserDataDirectory); packagedSmokeEvidence?.write('desktop.smoke.authorized'); } } catch { @@ -62,9 +78,40 @@ try { const packagedSmokeTest = packagedSmokeUserDataDirectory !== null; let mainWindow: BrowserWindow | null = null; const initialDeepLink = deepLinkFromArguments(process.argv); +const nativeObservedEvents = new Set(); +let nativeRendererReady = false; +let nativeCompletionStarted = false; +let nativeProfiles: ProfileStore | null = null; +const recordNativeEvent = (event: string): void => { + if (!nativeSmokePhase) return; + if (event.endsWith('_once') && nativeObservedEvents.has(event)) { + packagedSmokeEvidence?.write('desktop.app.start_failed'); + app.exit(1); + return; + } + nativeObservedEvents.add(event); + packagedSmokeEvidence?.write(event); +}; +const nativeEventForDeliveredLink = (value: string): string | null => { + if (nativeSmokePhase === 'first') { + if (value === NATIVE_COLD_MANUAL_LINK) return 'desktop.deeplink.cold_manual_once'; + if (value === NATIVE_WARM_MANUAL_LINK) return 'desktop.deeplink.warm_manual_once'; + if (value === NATIVE_WARM_TUNNEL_LINK) return 'desktop.deeplink.warm_tunnel_once'; + if (value === NATIVE_WARM_OPEN_LINK) return 'desktop.deeplink.warm_open_once'; + } + if (nativeSmokePhase === 'relaunch' && value === NATIVE_COLD_TUNNEL_LINK) { + return 'desktop.deeplink.cold_tunnel_once'; + } + return null; +}; const deepLinkDelivery = new DeepLinkDelivery( IPC_CHANNELS.deepLink, initialDeepLink ? [initialDeepLink] : [], + value => { + const event = nativeEventForDeliveredLink(value); + if (event) recordNativeEvent(event); + maybeCompleteNativeFirstLaunch(); + }, ); let logger: DesktopLogger | null = null; let shutdownStarted = false; @@ -107,6 +154,62 @@ const deliverDeepLink = (value: string): void => { deepLinkDelivery.deliver(value); }; +function maybeCompleteNativeFirstLaunch(): void { + const required = [ + 'desktop.deeplink.cold_manual_once', + 'desktop.deeplink.warm_manual_once', + 'desktop.deeplink.warm_tunnel_once', + 'desktop.deeplink.warm_open_once', + 'desktop.deeplink.rejected_malformed', + 'desktop.deeplink.rejected_oversized', + 'desktop.deeplink.rejected_unsafe_scheme', + ]; + if (nativeSmokePhase !== 'first' || !nativeRendererReady || nativeCompletionStarted + || !required.every(event => nativeObservedEvents.has(event)) || !mainWindow || !nativeProfiles) return; + nativeCompletionStarted = true; + const window = mainWindow; + void (async () => { + const uiRequiresConfirmation = await window.webContents.executeJavaScript(`(async () => { + const deadline = performance.now() + 2000; + do { + const labels = Array.from(document.querySelectorAll('.desktop-connection-card form > label')); + if (labels[1]?.querySelector('input')?.value === 'https://t-native-evidence.propr.dev') return true; + await new Promise(resolve => setTimeout(resolve, 25)); + } while (performance.now() < deadline); + return false; + })()`); + const stored = await nativeProfiles?.list(); + const deepLinkedEndpoints = new Set([ + 'http://localhost:44111', + 'http://127.0.0.1:44112', + 'https://t-native-evidence.propr.dev', + ]); + if (!uiRequiresConfirmation || stored?.activeProfileId !== null || stored?.profiles.length !== 2 + || stored.profiles.some(profile => deepLinkedEndpoints.has(profile.apiBaseUrl))) { + throw new Error('Native deep-link endpoint was trusted without UI confirmation'); + } + recordNativeEvent('desktop.deeplink.confirmation_required'); + app.quit(); + })().catch(error => { + log('error', 'desktop.app.start_failed', { error }); + app.exit(1); + }); +} + +const recordNativeRejectedArguments = (argv: readonly string[]): void => { + if (nativeSmokePhase !== 'first') return; + if (argv.includes('native-evidence-malformed')) { + recordNativeEvent('desktop.deeplink.rejected_malformed'); + } + if (argv.includes('https://native-evidence.invalid/unsafe')) { + recordNativeEvent('desktop.deeplink.rejected_unsafe_scheme'); + } + if (argv.some(value => value.length > 2_048 && value.startsWith('propr://connect?api='))) { + recordNativeEvent('desktop.deeplink.rejected_oversized'); + } + maybeCompleteNativeFirstLaunch(); +}; + const configureSessionSecurity = (): void => { const desktopSession = session.defaultSession; desktopSession.setPermissionCheckHandler(() => false); @@ -304,7 +407,71 @@ const createMainWindow = async (): Promise => { } log('info', 'desktop.renderer.profile_api.ready', { origin: DESKTOP_RENDERER_ORIGIN }); } - if (packagedSmokeTest) { + if (nativeSmokePhase) { + const profiles = await nativeProfiles?.list(); + const expectedInitialApi = nativeSmokePhase === 'first' + ? 'http://localhost:44111' + : 'https://t-native-relaunch.propr.dev'; + const initialEndpointVisible = await window.webContents.executeJavaScript(`(async () => { + const deadline = performance.now() + 2000; + do { + const labels = Array.from(document.querySelectorAll('.desktop-connection-card form > label')); + if (labels[1]?.querySelector('input')?.value === ${JSON.stringify(expectedInitialApi)}) return true; + await new Promise(resolve => setTimeout(resolve, 25)); + } while (performance.now() < deadline); + return false; + })()`); + if (!initialEndpointVisible || !profiles) { + throw new Error('Native cold deep link did not reach the confirmation UI'); + } + if (nativeSmokePhase === 'first') { + if (profiles.profiles.length !== 0 || profiles.activeProfileId !== null) { + throw new Error('Native first launch did not start with an isolated profile'); + } + await nativeProfiles?.save({ + id: 'native-local', + label: 'Preserved local profile', + apiBaseUrl: 'http://localhost:44221', + }); + await nativeProfiles?.save({ + id: 'native-tunnel', + label: 'Native ProPR Connect tunnel', + apiBaseUrl: 'https://t-preserved.propr.dev', + }); + const storage = nativeProfiles?.security(); + const credentialWrite = await nativeProfiles?.writeCredential('native-local', 'native-custody-probe'); + if (!storage || !credentialWrite) throw new Error('Native secure-storage custody probe did not run'); + if (storage.available) { + if (storage.backend === 'basic_text' || !credentialWrite.stored + || (await nativeProfiles?.readCredential('native-local'))?.value !== 'native-custody-probe') { + throw new Error('Native secure-storage custody probe did not use OS encryption'); + } + await nativeProfiles?.removeCredential('native-local'); + if ((await nativeProfiles?.readCredential('native-local'))?.value !== null) { + throw new Error('Native secure-storage custody probe cleanup failed'); + } + } else if (credentialWrite.stored + || (await nativeProfiles?.readCredential('native-local'))?.available !== false) { + throw new Error('Native secure-storage custody probe allowed plaintext fallback'); + } + if (process.platform === 'darwin' && (!storage.available || storage.backend !== 'os-protected')) { + throw new Error('Native macOS artifact did not retain Keychain-backed custody'); + } + recordNativeEvent('desktop.native.secure_storage_enforced'); + recordNativeEvent('desktop.native.profile_fresh'); + } else { + const expected = new Map([ + ['native-local', 'http://localhost:44221'], + ['native-tunnel', 'https://t-preserved.propr.dev'], + ]); + if (profiles.activeProfileId !== null || profiles.profiles.length !== expected.size + || profiles.profiles.some(profile => expected.get(profile.id) !== profile.apiBaseUrl)) { + throw new Error('Native relaunch did not preserve the non-secret profile state exactly'); + } + recordNativeEvent('desktop.native.profile_preserved'); + recordNativeEvent('desktop.deeplink.confirmation_required'); + } + } else if (packagedSmokeTest) { const profileFlow = await window.webContents.executeJavaScript(`(async () => { const bridge = window.proprDesktop; const local = await bridge.profiles.save({ label: 'Local setup', apiBaseUrl: 'http://localhost:4000' }); @@ -339,7 +506,9 @@ const createMainWindow = async (): Promise => { }); } log('info', 'desktop.renderer.ready', { preloadBridgeExposed: true }); - if (packagedSmokeTest) { + nativeRendererReady = true; + maybeCompleteNativeFirstLaunch(); + if (packagedSmokeTest && nativeSmokePhase !== 'first') { app.quit(); } else { window.show(); @@ -360,6 +529,7 @@ if (!hasSingleInstanceLock) { app.on('second-instance', (_event, argv) => { const deepLink = deepLinkFromArguments(argv); if (deepLink) deliverDeepLink(deepLink); + else recordNativeRejectedArguments(argv); if (mainWindow) { if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.show(); @@ -374,6 +544,16 @@ if (!hasSingleInstanceLock) { () => packagedSmokeEvidence?.write('desktop.log.write_failed'), ); log('info', 'desktop.app.ready', { version: app.getVersion(), platform: process.platform }); + if (nativeSmokePhase) { + const expectedVersion = process.env.PROPR_DESKTOP_NATIVE_EXPECTED_VERSION; + const expectedPlatform = process.env.PROPR_DESKTOP_NATIVE_EXPECTED_PLATFORM; + const expectedArch = process.env.PROPR_DESKTOP_NATIVE_EXPECTED_ARCH; + if (!expectedVersion || app.getVersion() !== expectedVersion + || expectedPlatform !== process.platform || expectedArch !== process.arch) { + throw new Error('Native artifact application identity, version, or architecture mismatch'); + } + recordNativeEvent('desktop.native.identity_verified'); + } configureSessionSecurity(); configurePackagedRendererProtocol(); @@ -391,6 +571,7 @@ if (!hasSingleInstanceLock) { decrypt: value => safeStorage.decryptString(value), }; const profiles = new ProfileStore(app.getPath('userData'), encryption); + nativeProfiles = profiles; const lifecycle = new LocalLifecycleController(); registerIpcHandlers({ app, diff --git a/apps/desktop/src/release-workflow.test.ts b/apps/desktop/src/release-workflow.test.ts index bf0a28a03..cb00ae2ff 100644 --- a/apps/desktop/src/release-workflow.test.ts +++ b/apps/desktop/src/release-workflow.test.ts @@ -26,6 +26,10 @@ const verifyDarwinImage = normalizeWorkflowText(readFileSync( fileURLToPath(new URL('../scripts/verify-darwin-image.mjs', import.meta.url)), 'utf8', )); +const nativeArtifactLifecycle = normalizeWorkflowText(readFileSync( + fileURLToPath(new URL('../scripts/test-native-artifact-lifecycle.mjs', import.meta.url)), + 'utf8', +)); const releasePreflight = normalizeWorkflowText(readFileSync( fileURLToPath(new URL('../scripts/release-preflight.mjs', import.meta.url)), 'utf8', @@ -317,6 +321,33 @@ describe('desktop trusted release workflow', () => { ); }); + test('exercises every staged Linux and macOS format through the native lifecycle gate', () => { + const validation = job('package', 'finalize'); + const stage = validation.indexOf('Stage architecture-verified validation artifacts with native DMG mount evidence'); + const lifecycle = validation.indexOf('Exercise staged native install, deep-link, relaunch, and removal lifecycle'); + const upload = validation.indexOf('Upload unsigned validation target'); + assert.ok(stage >= 0 && lifecycle > stage && upload > lifecycle); + assert.match(validation, /if: matrix\.platform == 'linux' \|\| matrix\.platform == 'darwin'/); + assert.match(validation, /dbus-run-session -- xvfb-run --auto-servernum/); + assert.equal(validation.match(/test-native-artifact-lifecycle\.mjs/g)?.length, 2); + assert.match(validation, /--artifact-directory "desktop-release-\$\{\{ matrix\.platform \}\}-\$\{\{ matrix\.arch \}\}"/); + assert.match(forgeConfig, /mimeType: \['x-scheme-handler\/propr'\]/); + + for (const kind of ['deb', 'rpm', 'zip', 'dmg']) { + assert.ok(nativeArtifactLifecycle.includes(`kind === '${kind}'`) || nativeArtifactLifecycle.includes(`kind !== '${kind}'`)); + } + for (const linkEvidence of [ + 'cold_manual_once', 'cold_tunnel_once', 'warm_manual_once', 'warm_tunnel_once', 'warm_open_once', + 'rejected_malformed', 'rejected_oversized', 'rejected_unsafe_scheme', 'confirmation_required', + ]) assert.ok(nativeArtifactLifecycle.includes(`desktop.deeplink.${linkEvidence}`)); + assert.match(nativeArtifactLifecycle, /inspectArtifactArchitecture/); + assert.match(nativeArtifactLifecycle, /assertSafeExtractedTree/); + assert.match(nativeArtifactLifecycle, /assertProfileAuthority/); + assert.match(nativeArtifactLifecycle, /LaunchServices-registration\+open-bundle-dispatch/); + assert.match(nativeArtifactLifecycle, /xdg-mime-registration\+gio-dispatch/); + assert.doesNotMatch(nativeArtifactLifecycle, /xattr|spctl|--no-sandbox|--disable-sandbox/); + }); + test('keeps both Windows architectures and the complete machine-scope installer contract mandatory', () => { for (const [jobName, section] of [ diff --git a/apps/desktop/src/smoke-test-evidence.test.ts b/apps/desktop/src/smoke-test-evidence.test.ts index d0ff7beea..885552e16 100644 --- a/apps/desktop/src/smoke-test-evidence.test.ts +++ b/apps/desktop/src/smoke-test-evidence.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import { describe, it } from 'node:test'; import { createPackagedSmokeEvidenceSink, + NATIVE_SMOKE_EVIDENCE_FILES, PACKAGED_SMOKE_EVIDENCE_EVENTS, PACKAGED_SMOKE_EVIDENCE_FILE, } from './smoke-test-evidence'; @@ -73,4 +74,17 @@ describe('packaged smoke evidence', () => { assert.ok(Buffer.byteLength(contents, 'utf8') < 1024); }); }); + + it('uses separate fixed event-only files for native first launch and relaunch', () => { + withSmokeDirectory(directory => { + const first = createPackagedSmokeEvidenceSink(directory, 'first'); + const relaunch = createPackagedSmokeEvidenceSink(directory, 'relaunch'); + assert.ok(first && relaunch); + first.write('desktop.native.profile_fresh'); + relaunch.write('desktop.native.profile_preserved'); + first.close(); + relaunch.close(); + assert.deepEqual(readdirSync(directory).sort(), Object.values(NATIVE_SMOKE_EVIDENCE_FILES).sort()); + }); + }); }); diff --git a/apps/desktop/src/smoke-test-evidence.ts b/apps/desktop/src/smoke-test-evidence.ts index a9d26bfb6..d446aa7b9 100644 --- a/apps/desktop/src/smoke-test-evidence.ts +++ b/apps/desktop/src/smoke-test-evidence.ts @@ -9,6 +9,10 @@ import { import { join } from 'node:path'; export const PACKAGED_SMOKE_EVIDENCE_FILE = 'application.smoke-evidence.jsonl'; +export const NATIVE_SMOKE_EVIDENCE_FILES = Object.freeze({ + first: 'application.smoke-evidence.first.jsonl', + relaunch: 'application.smoke-evidence.relaunch.jsonl', +}); export const PACKAGED_SMOKE_EVIDENCE_EVENTS = [ 'desktop.smoke.authorized', @@ -23,9 +27,28 @@ export const PACKAGED_SMOKE_EVIDENCE_EVENTS = [ 'desktop.log.write_failed', ] as const; +export const NATIVE_SMOKE_EVIDENCE_EVENTS = [ + 'desktop.native.identity_verified', + 'desktop.native.profile_fresh', + 'desktop.native.profile_preserved', + 'desktop.native.secure_storage_enforced', + 'desktop.deeplink.cold_manual_once', + 'desktop.deeplink.cold_tunnel_once', + 'desktop.deeplink.warm_manual_once', + 'desktop.deeplink.warm_tunnel_once', + 'desktop.deeplink.warm_open_once', + 'desktop.deeplink.confirmation_required', + 'desktop.deeplink.rejected_malformed', + 'desktop.deeplink.rejected_oversized', + 'desktop.deeplink.rejected_unsafe_scheme', +] as const; + export type PackagedSmokeEvidenceEvent = typeof PACKAGED_SMOKE_EVIDENCE_EVENTS[number]; -const allowedEvents = new Set(PACKAGED_SMOKE_EVIDENCE_EVENTS); +const allowedEvents = new Set([ + ...PACKAGED_SMOKE_EVIDENCE_EVENTS, + ...NATIVE_SMOKE_EVIDENCE_EVENTS, +]); export interface PackagedSmokeEvidenceSink { write(event: string): void; @@ -34,10 +57,12 @@ export interface PackagedSmokeEvidenceSink { export const createPackagedSmokeEvidenceSink = ( authorizedUserDataDirectory: string | null, + nativePhase?: keyof typeof NATIVE_SMOKE_EVIDENCE_FILES, ): PackagedSmokeEvidenceSink | null => { if (authorizedUserDataDirectory === null) return null; - const evidencePath = join(authorizedUserDataDirectory, PACKAGED_SMOKE_EVIDENCE_FILE); + const evidenceFile = nativePhase ? NATIVE_SMOKE_EVIDENCE_FILES[nativePhase] : PACKAGED_SMOKE_EVIDENCE_FILE; + const evidencePath = join(authorizedUserDataDirectory, evidenceFile); const descriptor = openSync(evidencePath, 'wx', 0o600); let closed = false; const emitted = new Set(); From 7c498d7a19de5e204f5e93d19702d05b48d5f5be 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:05:21 +0000 Subject: [PATCH 2/5] feat(ai): Implemented the complete scoped macOS/Linux follow-up on base HEAD `603ef9c688a65390f2dff55646223dff3b75193f`. Implemented the complete scoped macOS/Linux follow-up on base HEAD `603ef9c688a65390f2dff55646223dff3b75193f`. Key changes: - Total bounded process-group cleanup for initial launch, dispatches, and relaunch. - Immediate DMG mount authority with mandatory verified detach. - Fail-closed LaunchServices unregister and exact copied-bundle absence checks. - Bounded profile API shutdown, listening-port proof, authorized profile removal, and redacted aggregate failures. - Renderer acknowledgements proving ordered, exactly-once Connect/Open consumption. - Cold launches explicitly reported as direct argv; OS protocol testing remains warm-only. - Linux secure-storage evidence explicitly fallback-only without claiming libsecret custody. - RPM pipeline now awaits both processes and handles late failures. - Added adversarial cleanup, symlink/special-file, and process fixtures. - Updated [native lifecycle documentation](). Verification: - Desktop typecheck: passed. - Desktop suite: 198 tests, 192 passed, 6 platform skips. - Renderer deep-link tests: 5 passed. - Native authority fixtures: 11 passed. - `git diff --check`: passed. - Full repository suite reached test 197 before blocking on unavailable Redis at `127.0.0.1:6379`. - Native artifact execution requires staged artifacts and the four Linux/macOS architecture runners, unavailable locally. - `package-lock.json`, workflows, Windows behavior, signing/notarization, update credentials, and release profiles remain unchanged. - Locked `qs` 6.16.0 and `fast-uri` 3.1.6 confirmed. Changes are left uncommitted and unmerged for audit. PR: #2085 Comment by: @integry (ID: 5516001642) Model: gpt-5.6-sol --- apps/desktop/README.md | 15 +- .../test-native-artifact-lifecycle.mjs | 780 ++++++++++++++---- .../test-native-artifact-lifecycle.test.mjs | 206 ++++- apps/desktop/src/deep-link-delivery.test.ts | 157 ++-- apps/desktop/src/deep-link-delivery.ts | 116 ++- apps/desktop/src/ipc.ts | 16 + apps/desktop/src/main.ts | 46 +- apps/desktop/src/preload-bridge.test.ts | 35 +- apps/desktop/src/preload-bridge.ts | 37 +- apps/desktop/src/shared/contract.ts | 17 +- apps/desktop/src/smoke-test-evidence.ts | 1 + propr-ui/src/desktop-deep-link.test.ts | 10 +- propr-ui/src/desktop-deep-link.ts | 10 +- propr-ui/src/desktop.tsx | 12 +- 14 files changed, 1198 insertions(+), 260 deletions(-) diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 9b243d39d..7cc1b14b6 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -164,18 +164,25 @@ rm -r "$install_root" The pull-request native gate runs on `ubuntu-24.04`/`ubuntu-24.04-arm` and `macos-15-intel`/`macos-15`. It consumes the canonical staged bytes: DEB/RPM are extracted with `dpkg-deb`/`rpm2cpio` -and ZIP with the platform archive tool; DMG is mounted read-only and copied with `ditto`. Every format gets a first -launch, clean shutdown, preserved-state relaunch, and owned-root removal. The gate rechecks the executable architecture, +and ZIP with the platform archive tool; DMG is mounted read-only and copied with `ditto`. DMG authority begins as soon +as attach succeeds, and detach plus an absent-mount postcondition is mandatory before its mount root is removed. Every +format gets a first launch, clean shutdown, preserved-state relaunch, and owned-root removal. The gate rechecks the executable architecture, identity/version, launcher or bundle, safe paths/symlinks, 0700/0600 profile authority, unchanged artifact bytes, and absence of default-profile leakage. A fixed non-secret custody probe must either round-trip through OS encryption or be refused with no `basic_text`/plaintext fallback; macOS additionally requires the OS-protected Keychain backend. Evidence files contain fixed event names only—never endpoints, paths, credentials, -or process output. +or process output. Deep-link events are written only after the already-loaded renderer acknowledges the exact consumed +confirmation or queued navigation state; each dispatch waits for that bounded acknowledgement before the next begins. Linux DEB/RPM protocol evidence uses an isolated XDG MIME database and a CI-relocated copy of the package's real desktop launcher, then dispatches with `gio`; ZIP has no registered launcher, so its single-instance dispatch evidence is direct and is reported as that limitation. macOS registers the copied bundle with LaunchServices and dispatches with -`open -b`. LaunchServices' own database writes are OS-managed evidence, not application profile writes. These checks do +`open -b`; unregister failure or a stale exact copied-bundle record fails cleanup. LaunchServices' database writes are +OS-managed state, distinct from app-owned profile writes, and are not reported as cleaned while that copied record remains. +Cold starts for every format are direct executable argv launches, not OS protocol launches; OS protocol dispatch evidence +is warm-only. The Linux job does not forward its outer session-bus address or provision a scoped Secret Service, so its +secure-storage result is explicitly fallback-only: `basic_text`/plaintext storage must be refused, and installed +`libsecret` is not claimed as exercised custody. These checks do not claim end-user Gatekeeper approval for unsigned builds, signing, notarization, or behavior on a desktop session that the hosted runner cannot provide. diff --git a/apps/desktop/scripts/test-native-artifact-lifecycle.mjs b/apps/desktop/scripts/test-native-artifact-lifecycle.mjs index 90192a2c1..f010a73ff 100644 --- a/apps/desktop/scripts/test-native-artifact-lifecycle.mjs +++ b/apps/desktop/scripts/test-native-artifact-lifecycle.mjs @@ -16,6 +16,7 @@ import { writeFile, } from 'node:fs/promises'; import { createServer } from 'node:http'; +import { createConnection } from 'node:net'; import { arch as hostArch, platform as hostPlatform, tmpdir } from 'node:os'; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { once } from 'node:events'; @@ -36,6 +37,7 @@ const APP_ID = 'dev.propr.desktop'; const PROCESS_TIMEOUT_MS = 45_000; const COMMAND_TIMEOUT_MS = 10 * 60_000; const OUTPUT_CAP = 64 * 1024; +const CLEANUP_GRACE_MS = 2_000; const COLD_MANUAL = 'propr://connect?api=http%3A%2F%2Flocalhost%3A44111'; const COLD_TUNNEL = 'propr://connect?api=https%3A%2F%2Ft-native-relaunch.propr.dev'; const WARM_MANUAL = 'propr://connect?api=http%3A%2F%2F127.0.0.1%3A44112'; @@ -121,6 +123,180 @@ const run = (file, args, { cwd, env, timeout = COMMAND_TIMEOUT_MS, input } = {}) }); }); +const delay = milliseconds => new Promise(resolveDelay => setTimeout(resolveDelay, milliseconds)); + +const errorFrom = (error, fallback) => error instanceof Error ? error : new Error(fallback); + +export class NativeLifecycleFailure extends AggregateError { + constructor(primaryError, cleanupFailures) { + const cleanupLabels = cleanupFailures.map(failure => failure.label).sort(); + const message = primaryError + ? `Native lifecycle failed; cleanup also failed: ${cleanupLabels.join(', ')}` + : `Native lifecycle cleanup failed: ${cleanupLabels.join(', ')}`; + const safeErrors = [ + ...(primaryError ? [new Error('Native lifecycle primary operation failed')] : []), + ...cleanupLabels.map(label => new Error(`Native lifecycle cleanup failed: ${label}`)), + ]; + super(safeErrors, message); + this.name = 'NativeLifecycleFailure'; + Object.defineProperties(this, { + primaryError: { value: primaryError, enumerable: false }, + cleanupFailures: { value: cleanupFailures, enumerable: false }, + }); + } +} + +const throwCombined = (primaryError, cleanupFailures) => { + if (cleanupFailures.length > 0) throw new NativeLifecycleFailure(primaryError, cleanupFailures); + if (primaryError) throw primaryError; +}; + +const processGroupExists = pid => { + if (!pid) return false; + try { + process.kill(-pid, 0); + return true; + } catch (error) { + if (error?.code === 'ESRCH') return false; + throw error; + } +}; + +const waitUntil = async (predicate, timeout) => { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + if (!predicate()) return true; + await delay(25); + } + return !predicate(); +}; + +class OwnedProcessGroup { + constructor(child) { + this.child = child; + this.pid = child.pid; + this.closed = false; + this.released = false; + this.result = null; + this.closePromise = new Promise(resolveClose => { + child.once('error', error => { + if (!this.result) this.result = { code: null, error, signal: null }; + }); + child.once('close', (code, signal) => { + this.closed = true; + this.result = { code, error: this.result?.error, signal }; + resolveClose(this.result); + }); + }); + } + + signal(signal) { + if (!this.pid) return; + try { + process.kill(-this.pid, signal); + } catch (error) { + if (error?.code !== 'ESRCH') throw error; + } + } + + async waitForClose(timeout) { + if (this.closed) return this.result; + return Promise.race([ + this.closePromise, + delay(timeout).then(() => { throw new Error('Native application close deadline expired'); }), + ]); + } + + async terminate() { + if (this.released) { + await this.waitForClose(CLEANUP_GRACE_MS); + return; + } + if (processGroupExists(this.pid)) { + this.signal('SIGTERM'); + if (!await waitUntil(() => processGroupExists(this.pid), CLEANUP_GRACE_MS)) { + this.signal('SIGKILL'); + } + } + const groupGone = await waitUntil(() => processGroupExists(this.pid), CLEANUP_GRACE_MS); + let closeError; + try { + await this.waitForClose(CLEANUP_GRACE_MS); + } catch (error) { + closeError = errorFrom(error, 'Native application close postcondition failed'); + } + if (!groupGone && closeError) { + throw new Error('Native application close and process-group cleanup deadlines expired'); + } + if (!groupGone) throw new Error('Native application process-group cleanup deadline expired'); + if (closeError) throw closeError; + if (processGroupExists(this.pid)) throw new Error('Native application left a process in its owned process group'); + this.released = true; + } + + async waitForSuccessfulExit(timeout = PROCESS_TIMEOUT_MS) { + let result; + try { + result = await this.waitForClose(timeout); + } catch (error) { + const cleanupFailures = []; + try { + await this.terminate(); + } catch (cleanupError) { + cleanupFailures.push({ + label: 'process-groups', + error: errorFrom(cleanupError, 'Process-group cleanup failed'), + }); + } + throwCombined(errorFrom(error, 'Native application close failed'), cleanupFailures); + } + if (processGroupExists(this.pid)) { + const primaryError = new Error('Native application main process exited before its owned process group'); + const cleanupFailures = []; + try { + await this.terminate(); + } catch (cleanupError) { + cleanupFailures.push({ + label: 'process-groups', + error: errorFrom(cleanupError, 'Process-group cleanup failed'), + }); + } + throwCombined(primaryError, cleanupFailures); + } + // Relinquish authority only after proving that the complete group is gone; + // this also prevents a later cleanup pass from acting on a reused PID. + this.released = true; + if (result?.error) throw result.error; + if (result?.code !== 0) { + throw new Error(`Native application exited with code ${result?.code ?? 'null'} signal ${result?.signal ?? 'none'}`); + } + } +} + +export class OwnedProcessGroups { + constructor() { + this.groups = []; + } + + track(child) { + const group = new OwnedProcessGroup(child); + this.groups.push(group); + return group; + } + + async cleanup() { + const failures = []; + for (const group of [...this.groups].reverse()) { + try { + await group.terminate(); + } catch (error) { + failures.push({ label: 'process-groups', error: errorFrom(error, 'Process-group cleanup failed') }); + } + } + return failures; + } +} + const digest = async path => createHash('sha256').update(await readFile(path)).digest('hex'); const inspectStagedArtifact = async ({ artifact, kind, target, workRoot }) => { @@ -170,7 +346,7 @@ export const assertArtifactSet = async target => { return expectedKinds; }; -const assertSafeExtractedTree = async root => { +export const assertSafeExtractedTree = async root => { const canonicalRoot = await realpath(root); const visit = async directory => { for (const entry of await readdir(directory, { withFileTypes: true })) { @@ -181,6 +357,10 @@ const assertSafeExtractedTree = async root => { if (!fromRoot || fromRoot === '..' || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) { throw new Error('Native artifact contains a symlink escaping its install root'); } + const targetStats = await stat(path); + if (!targetStats.isFile() && !targetStats.isDirectory()) { + throw new Error('Native artifact contains a symlink to an unsupported filesystem entry'); + } } else if (entry.isDirectory()) { await visit(path); } else if (!entry.isFile()) { @@ -191,40 +371,81 @@ const assertSafeExtractedTree = async root => { await visit(root); }; -const extractRpm = async (artifact, root) => { - await new Promise((resolveExtraction, reject) => { - const converter = spawn('/usr/bin/rpm2cpio', [artifact], { shell: false, stdio: ['ignore', 'pipe', 'pipe'] }); - const extractor = spawn( - '/usr/bin/cpio', - ['--extract', '--make-directories', '--no-absolute-filenames', '--quiet'], - { cwd: root, shell: false, stdio: ['pipe', 'ignore', 'pipe'] }, - ); - converter.stdout.pipe(extractor.stdin); - let failure; - let diagnostics = Buffer.alloc(0); - converter.stderr.on('data', chunk => { diagnostics = appendBounded(diagnostics, chunk); }); - extractor.stderr.on('data', chunk => { diagnostics = appendBounded(diagnostics, chunk); }); - converter.once('error', error => { failure = error; extractor.kill('SIGKILL'); }); - extractor.once('error', error => { failure = error; converter.kill('SIGKILL'); }); - converter.once('close', code => { - if (code !== 0 && !failure) { - failure = new Error(`rpm2cpio failed with code ${code ?? 'null'}`); - extractor.kill('SIGKILL'); - } - }); - const timer = setTimeout(() => { - failure = new Error('RPM extraction deadline expired'); - converter.kill('SIGKILL'); - extractor.kill('SIGKILL'); - }, COMMAND_TIMEOUT_MS); - extractor.once('close', code => { - clearTimeout(timer); - if (failure) reject(failure); - else if (code !== 0) reject(new Error(`cpio failed with code ${code ?? 'null'}`)); - else if (diagnostics.length !== 0) reject(new Error('RPM extraction emitted unexpected diagnostics')); - else resolveExtraction(); - }); - }); +const waitForPipelineProcess = child => new Promise(resolveProcess => { + let spawnError; + child.once('error', error => { spawnError = error; }); + child.once('close', (code, signal) => resolveProcess({ code, error: spawnError, signal })); +}); + +const stopPipelineProcess = async (child, completion) => { + const running = child.exitCode === null + && (child.signalCode === undefined || child.signalCode === null); + if (running) child.kill('SIGTERM'); + const result = await Promise.race([completion, delay(CLEANUP_GRACE_MS).then(() => null)]); + if (result) return result; + child.kill('SIGKILL'); + return Promise.race([ + completion, + delay(CLEANUP_GRACE_MS).then(() => { throw new Error('RPM extraction process cleanup deadline expired'); }), + ]); +}; + +export const extractRpm = async (artifact, root, { + converterFile = '/usr/bin/rpm2cpio', + extractorFile = '/usr/bin/cpio', + spawnProcess = spawn, + timeout = COMMAND_TIMEOUT_MS, +} = {}) => { + const converter = spawnProcess(converterFile, [artifact], { shell: false, stdio: ['ignore', 'pipe', 'pipe'] }); + const extractor = spawnProcess( + extractorFile, + ['--extract', '--make-directories', '--no-absolute-filenames', '--quiet'], + { cwd: root, shell: false, stdio: ['pipe', 'ignore', 'pipe'] }, + ); + const converterCompletion = waitForPipelineProcess(converter); + const extractorCompletion = waitForPipelineProcess(extractor); + const stopExtractorOnConverterFailure = converterCompletion.then(result => ( + result.error || result.code !== 0 ? stopPipelineProcess(extractor, extractorCompletion) : undefined + )); + const stopConverterOnExtractorFailure = extractorCompletion.then(result => ( + result.error || result.code !== 0 ? stopPipelineProcess(converter, converterCompletion) : undefined + )); + let diagnostics = Buffer.alloc(0); + converter.stderr.on('data', chunk => { diagnostics = appendBounded(diagnostics, chunk); }); + extractor.stderr.on('data', chunk => { diagnostics = appendBounded(diagnostics, chunk); }); + converter.stdout.pipe(extractor.stdin); + + let results; + try { + results = await Promise.race([ + Promise.all([ + converterCompletion, + extractorCompletion, + stopExtractorOnConverterFailure, + stopConverterOnExtractorFailure, + ]).then(([converterResult, extractorResult]) => [converterResult, extractorResult]), + delay(timeout).then(() => { throw new Error('RPM extraction deadline expired'); }), + ]); + } catch (error) { + const cleanup = await Promise.allSettled([ + stopPipelineProcess(converter, converterCompletion), + stopPipelineProcess(extractor, extractorCompletion), + ]); + const cleanupFailures = cleanup + .filter(result => result.status === 'rejected') + .map(result => ({ label: 'rpm-processes', error: result.reason })); + throwCombined(errorFrom(error, 'RPM extraction failed'), cleanupFailures); + } + const [converterResult, extractorResult] = results; + if (converterResult.error) throw converterResult.error; + if (extractorResult.error) throw extractorResult.error; + if (converterResult.code !== 0) { + throw new Error(`rpm2cpio failed with code ${converterResult.code ?? 'null'} signal ${converterResult.signal ?? 'none'}`); + } + if (extractorResult.code !== 0) { + throw new Error(`cpio failed with code ${extractorResult.code ?? 'null'} signal ${extractorResult.signal ?? 'none'}`); + } + if (diagnostics.length !== 0) throw new Error('RPM extraction emitted unexpected diagnostics'); }; const locateApplication = async ({ platform, arch, kind, installRoot }) => { @@ -249,7 +470,81 @@ const locateApplication = async ({ platform, arch, kind, installRoot }) => { }; }; -const extractArtifact = async ({ artifact, kind, target, installRoot, mountRoot }) => { +const mountOutputContains = (output, mountRoot) => output.toString('utf8') + .split(/\r?\n/) + .some(line => line.trimEnd().endsWith(mountRoot)); + +export class DmgMountAuthority { + constructor(mountRoot, { runCommand = run } = {}) { + this.mountRoot = mountRoot; + this.runCommand = runCommand; + this.mounted = false; + } + + async attach(artifact) { + await this.runCommand('/usr/bin/hdiutil', [ + 'attach', '-readonly', '-nobrowse', '-mountpoint', this.mountRoot, artifact, + ]); + this.mounted = true; + } + + async detach() { + if (!this.mounted) return; + let detachError; + try { + await this.runCommand('/usr/bin/hdiutil', ['detach', this.mountRoot], { timeout: 30_000 }); + } catch (error) { + detachError = errorFrom(error, 'DMG detach failed'); + } + let mounts; + let queryError; + try { + mounts = await this.runCommand('/usr/bin/hdiutil', ['info'], { timeout: 30_000 }); + } catch (error) { + queryError = errorFrom(error, 'DMG mount postcondition query failed'); + } + const stale = mounts ? mountOutputContains(mounts.stdout, this.mountRoot) : false; + if (!queryError && !stale) this.mounted = false; + const failures = []; + if (detachError) failures.push({ label: 'dmg-detach', error: detachError }); + if (queryError) failures.push({ label: 'dmg-mount-query', error: queryError }); + if (stale) failures.push({ label: 'dmg-mounted-postcondition', error: new Error('DMG mount remained active') }); + throwCombined(null, failures); + } +} + +export const extractDmg = async ({ + artifact, + installRoot, + mountAuthority, + readDirectory = readdir, + runCommand = run, +}) => { + let primaryError; + try { + await mountAuthority.attach(artifact); + const applications = (await readDirectory(mountAuthority.mountRoot, { withFileTypes: true })) + .filter(entry => entry.isDirectory() && entry.name.endsWith('.app')); + if (applications.length !== 1) throw new Error('Mounted DMG has a missing or duplicate application identity'); + await runCommand('/usr/bin/ditto', [ + join(mountAuthority.mountRoot, applications[0].name), + join(installRoot, applications[0].name), + ]); + } catch (error) { + primaryError = errorFrom(error, 'DMG extraction failed'); + } + const cleanupFailures = []; + if (mountAuthority.mounted) { + try { + await mountAuthority.detach(); + } catch (error) { + cleanupFailures.push({ label: 'dmg-mount', error: errorFrom(error, 'DMG cleanup failed') }); + } + } + throwCombined(primaryError, cleanupFailures); +}; + +const extractArtifact = async ({ artifact, kind, target, installRoot, mountAuthority }) => { if (kind === 'deb') { await run('/usr/bin/dpkg-deb', ['--extract', artifact, installRoot]); } else if (kind === 'rpm') { @@ -259,11 +554,7 @@ const extractArtifact = async ({ artifact, kind, target, installRoot, mountRoot } else if (kind === 'zip') { await run('/usr/bin/ditto', ['-x', '-k', artifact, installRoot]); } else { - await run('/usr/bin/hdiutil', ['attach', '-readonly', '-nobrowse', '-mountpoint', mountRoot, artifact]); - const applications = (await readdir(mountRoot, { withFileTypes: true })) - .filter(entry => entry.isDirectory() && entry.name.endsWith('.app')); - if (applications.length !== 1) throw new Error('Mounted DMG has a missing or duplicate application identity'); - await run('/usr/bin/ditto', [join(mountRoot, applications[0].name), join(installRoot, applications[0].name)]); + await extractDmg({ artifact, installRoot, mountAuthority }); } }; @@ -306,10 +597,12 @@ const validateIdentity = async ({ target, kind, application }) => { } }; -const waitForEvents = async (path, events, child, timeout = PROCESS_TIMEOUT_MS) => { +export const waitForEvents = async (path, events, child, timeout = PROCESS_TIMEOUT_MS) => { const deadline = Date.now() + timeout; while (Date.now() < deadline) { - if (child.exitCode !== null) throw new Error('Native application exited before producing required evidence'); + const exited = child.exitCode !== null + || (child.signalCode !== undefined && child.signalCode !== null); + if (exited) throw new Error('Native application exited before producing required evidence'); try { const records = (await readFile(path, 'utf8')).trim().split('\n').filter(Boolean).map(line => JSON.parse(line)); const names = records.map(record => record.event); @@ -325,68 +618,42 @@ const waitForEvents = async (path, events, child, timeout = PROCESS_TIMEOUT_MS) throw new Error('Native application evidence deadline expired'); }; -const signalApplicationGroup = (child, signal) => { - if (!child.pid) return; - try { - process.kill(-child.pid, signal); - } catch (error) { - if (error?.code !== 'ESRCH') throw error; - } -}; - -const assertApplicationGroupGone = async child => { - if (!child.pid) return; - const deadline = Date.now() + 5_000; - while (Date.now() < deadline) { - try { - process.kill(-child.pid, 0); - } catch (error) { - if (error?.code === 'ESRCH') return; - throw error; +const assertEvidenceOrdering = async (path, requiredEvents) => { + const records = (await readFile(path, 'utf8')).trim().split('\n').filter(Boolean).map(line => JSON.parse(line)); + const names = records.map(record => record.event); + let previous = -1; + for (const event of requiredEvents) { + const occurrences = names.reduce((count, name) => count + Number(name === event), 0); + const index = names.indexOf(event); + if (occurrences !== 1 || index <= previous) { + throw new Error('Native application evidence was duplicated or out of order'); } - await new Promise(resolveWait => setTimeout(resolveWait, 50)); + previous = index; } - signalApplicationGroup(child, 'SIGKILL'); - throw new Error('Native application left a process in its owned process group'); }; -const waitForExit = (child, timeout = PROCESS_TIMEOUT_MS) => new Promise((resolveExit, reject) => { - const complete = (code, signal) => { - void assertApplicationGroupGone(child).then(() => { - if (code === 0) resolveExit(); - else reject(new Error(`Native application exited with code ${code ?? 'null'} signal ${signal ?? 'none'}`)); - }, reject); - }; - if (child.exitCode !== null) { - complete(child.exitCode, child.signalCode); - return; - } - const timer = setTimeout(() => { - signalApplicationGroup(child, 'SIGKILL'); - reject(new Error('Native application shutdown deadline expired')); - }, timeout); - child.once('close', (code, signal) => { - clearTimeout(timer); - complete(code, signal); - }); -}); - -const startApplication = (application, args, env, cwd) => spawn(application.executable, args, { +const startApplication = (application, args, env, cwd, processGroups) => processGroups.track(spawn(application.executable, args, { cwd, env, detached: true, shell: false, stdio: ['ignore', 'ignore', 'ignore'], -}); +})); -const dispatchDirect = async (application, userData, link, env) => { - const child = startApplication(application, [`--user-data-dir=${userData}`, link], env, dirname(application.applicationRoot)); - await waitForExit(child, 15_000); +const dispatchDirect = async (application, userData, link, env, processGroups) => { + const group = startApplication( + application, + [`--user-data-dir=${userData}`, link], + env, + dirname(application.applicationRoot), + processGroups, + ); + await group.waitForSuccessfulExit(15_000); }; -const linuxProtocolDispatch = async ({ application, profile, link, env }) => { +const linuxProtocolDispatch = async ({ application, profile, link, env, processGroups }) => { if (!application.desktopFile) { - await dispatchDirect(application, profile.userData, link, env); + await dispatchDirect(application, profile.userData, link, env, processGroups); return 'direct-second-instance; ZIP has no OS launcher registration'; } const applications = join(profile.xdgData, 'applications'); @@ -406,9 +673,45 @@ const linuxProtocolDispatch = async ({ application, profile, link, env }) => { return 'xdg-mime-registration+gio-dispatch (CI-relocated package launcher)'; }; -const macProtocolDispatch = async ({ application, link, env }) => { - const launchServices = '/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister'; - await run(launchServices, ['-f', application.applicationRoot], { env }); +const LAUNCH_SERVICES = '/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister'; + +export class LaunchServicesAuthority { + constructor(applicationRoot, environment, { runCommand = run } = {}) { + this.applicationRoot = applicationRoot; + this.environment = environment; + this.runCommand = runCommand; + this.registered = false; + } + + async register() { + await this.runCommand(LAUNCH_SERVICES, ['-f', this.applicationRoot], { env: this.environment, timeout: 30_000 }); + this.registered = true; + } + + async unregister() { + if (!this.registered) return; + await this.runCommand(LAUNCH_SERVICES, ['-u', this.applicationRoot], { env: this.environment, timeout: 30_000 }); + } + + async assertGone() { + const result = await this.runCommand(LAUNCH_SERVICES, ['-dump'], { env: this.environment, timeout: 30_000 }); + if (result.stdout.toString('utf8').split(/\r?\n/).some(line => { + const record = line.trim(); + const index = record.indexOf(this.applicationRoot); + if (index < 0) return false; + const before = record[index - 1]; + const after = record[index + this.applicationRoot.length]; + return (index === 0 || /[\s:"'=]/.test(before)) + && (after === undefined || /[\s"',)]/.test(after)); + })) { + throw new Error('Copied application remained registered with LaunchServices'); + } + this.registered = false; + } +} + +const macProtocolDispatch = async ({ launchServices, link, env }) => { + await launchServices.register(); await run('/usr/bin/open', ['-b', APP_ID, link], { env, timeout: 15_000 }); return 'LaunchServices-registration+open-bundle-dispatch'; }; @@ -441,17 +744,136 @@ const createProfileApi = async () => { ? '{"product":"ProPR","desktopAuthentication":{"protocolVersion":1}}' : '{"profileEndpoint":true}'); }); - server.listen(0, '127.0.0.1'); - await once(server, 'listening'); - const address = server.address(); - if (!address || typeof address === 'string') throw new Error('Native profile API did not bind safely'); - return { server, url: `http://127.0.0.1:${address.port}` }; + try { + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Native profile API did not bind safely'); + return { port: address.port, server, url: `http://127.0.0.1:${address.port}` }; + } catch (error) { + const primaryError = errorFrom(error, 'Native profile API creation failed'); + const cleanupFailures = []; + if (server.listening) { + try { + const address = server.address(); + if (!address || typeof address === 'string') { + try { + server.closeAllConnections(); + } finally { + await Promise.race([ + new Promise((resolveClose, rejectClose) => server.close(closeError => ( + closeError ? rejectClose(closeError) : resolveClose() + ))), + delay(CLEANUP_GRACE_MS).then(() => { + throw new Error('Native profile API setup close deadline expired'); + }), + ]); + } + if (server.listening || server.address() !== null) { + throw new Error('Native profile API retained listening authority after setup failure'); + } + } else { + await closeProfileApi({ server, port: address.port }); + } + } catch (cleanupError) { + cleanupFailures.push({ + label: 'profile-api-setup', + error: errorFrom(cleanupError, 'Native profile API setup cleanup failed'), + }); + } + } + throwCombined(primaryError, cleanupFailures); + } }; -const closeServer = async server => { - if (!server.listening) return; - server.closeAllConnections(); - await new Promise((resolveClose, rejectClose) => server.close(error => error ? rejectClose(error) : resolveClose())); +const assertPortClosed = (port, timeout = CLEANUP_GRACE_MS) => new Promise((resolveClosed, rejectClosed) => { + const socket = createConnection({ host: '127.0.0.1', port }); + const timer = setTimeout(() => { + socket.destroy(); + rejectClosed(new Error('Native profile API close postcondition deadline expired')); + }, timeout); + socket.once('connect', () => { + clearTimeout(timer); + socket.destroy(); + rejectClosed(new Error('Native profile API remained reachable after close')); + }); + socket.once('error', error => { + clearTimeout(timer); + if (error?.code === 'ECONNREFUSED') resolveClosed(); + else rejectClosed(new Error('Native profile API close postcondition failed')); + }); +}); + +export const closeProfileApi = async ({ server, port }, { + closeDeadline = CLEANUP_GRACE_MS, + probeClosed = assertPortClosed, +} = {}) => { + const failures = []; + let closeError; + if (server.listening) { + try { + server.closeAllConnections(); + } catch (error) { + failures.push({ + label: 'profile-api-connections', + error: errorFrom(error, 'Native profile API connection cleanup failed'), + }); + } + try { + await Promise.race([ + new Promise((resolveClose, rejectClose) => server.close(error => ( + error ? rejectClose(error) : resolveClose() + ))), + delay(closeDeadline).then(() => { throw new Error('Native profile API close deadline expired'); }), + ]); + } catch (error) { + closeError = errorFrom(error, 'Native profile API close failed'); + } + } + if (closeError) failures.push({ label: 'profile-api-close', error: closeError }); + if (server.listening || server.address() !== null) { + failures.push({ label: 'profile-api-listening', error: new Error('Native profile API retained listening authority') }); + } else { + try { + await probeClosed(port); + } catch (error) { + failures.push({ label: 'profile-api-postcondition', error: errorFrom(error, 'Native profile API postcondition failed') }); + } + } + throwCombined(null, failures); +}; + +const assertAbsent = async (path, message) => { + try { + await lstat(path); + } catch (error) { + if (error?.code === 'ENOENT') return; + throw error; + } + throw new Error(message); +}; + +export const removeAuthorizedProfile = async (profile, { + inspectPath = lstat, + removeProfile = removePrivateSmokeProfile, +} = {}) => { + let removalError; + try { + await removeProfile(profile); + } catch (error) { + removalError = errorFrom(error, 'Native private profile authority cleanup failed'); + } + let postconditionError; + try { + await inspectPath(profile.root); + postconditionError = new Error('Native private profile remained after authority cleanup'); + } catch (error) { + if (error?.code !== 'ENOENT') postconditionError = errorFrom(error, 'Native private profile postcondition failed'); + } + const failures = []; + if (removalError) failures.push({ label: 'profile-authority', error: removalError }); + if (postconditionError) failures.push({ label: 'profile-postcondition', error: postconditionError }); + throwCombined(null, failures); }; const defaultUserDataCandidates = target => { @@ -479,21 +901,26 @@ const assertDefaultUserDataUntouched = async target => { const lifecycleForArtifact = async ({ target, kind, artifact, report }) => { const workRoot = await mkdtemp(join(tmpdir(), `propr-native-${kind}-`)); - await chmod(workRoot, 0o700); const installRoot = join(workRoot, 'install'); const mountRoot = join(workRoot, 'mount'); - await mkdir(installRoot, { mode: 0o700 }); - await mkdir(mountRoot, { mode: 0o700 }); - const beforeDigest = await digest(artifact); - const profile = await createPrivateSmokeProfile(workRoot); - const profileApi = await createProfileApi(); + const processGroups = new OwnedProcessGroups(); + const mountAuthority = kind === 'dmg' ? new DmgMountAuthority(mountRoot) : null; + let profile; + let profileApi; let application; - let mounted = false; + let launchServices; + let sandboxPrepared = false; + let primaryError; try { + await chmod(workRoot, 0o700); + await mkdir(installRoot, { mode: 0o700 }); + await mkdir(mountRoot, { mode: 0o700 }); + const beforeDigest = await digest(artifact); + profile = await createPrivateSmokeProfile(workRoot); + profileApi = await createProfileApi(); await assertDefaultUserDataUntouched(target); await inspectStagedArtifact({ artifact, kind, target, workRoot }); - await extractArtifact({ artifact, kind, target, installRoot, mountRoot }); - mounted = kind === 'dmg'; + await extractArtifact({ artifact, kind, target, installRoot, mountAuthority }); application = await locateApplication({ ...target, kind, installRoot }); await assertSafeExtractedTree(installRoot); await validateIdentity({ target, kind, application }); @@ -501,6 +928,7 @@ const lifecycleForArtifact = async ({ target, kind, artifact, report }) => { const sandbox = join(application.applicationRoot, 'chrome-sandbox'); await run('/usr/bin/sudo', ['/usr/bin/chown', 'root:root', sandbox]); await run('/usr/bin/sudo', ['/usr/bin/chmod', '4755', sandbox]); + sandboxPrepared = true; } const baseEnvironment = await createSmokeChildEnvironment({ @@ -522,24 +950,46 @@ const lifecycleForArtifact = async ({ target, kind, artifact, report }) => { '--propr-smoke-test', `--user-data-dir=${profile.userData}`, COLD_MANUAL, - ], firstEnvironment, workRoot); + ], firstEnvironment, workRoot, processGroups); const firstEvidence = join(profile.userData, 'application.smoke-evidence.first.jsonl'); - await waitForEvents(firstEvidence, ['desktop.renderer.ready', 'desktop.deeplink.cold_manual_once'], first); - await dispatchDirect(application, profile.userData, WARM_MANUAL, dispatchEnvironment); + await waitForEvents(firstEvidence, ['desktop.renderer.ready', 'desktop.deeplink.cold_manual_once'], first.child); + await dispatchDirect(application, profile.userData, WARM_MANUAL, dispatchEnvironment, processGroups); + await waitForEvents(firstEvidence, ['desktop.deeplink.warm_manual_once'], first.child); + if (target.platform === 'darwin') { + launchServices = new LaunchServicesAuthority(application.applicationRoot, dispatchEnvironment); + } const protocol = target.platform === 'linux' - ? await linuxProtocolDispatch({ application, profile, link: WARM_TUNNEL, env: dispatchEnvironment }) - : await macProtocolDispatch({ application, link: WARM_TUNNEL, env: dispatchEnvironment }); - await dispatchDirect(application, profile.userData, WARM_OPEN, dispatchEnvironment); - await dispatchDirect(application, profile.userData, 'native-evidence-malformed', dispatchEnvironment); - await dispatchDirect(application, profile.userData, 'https://native-evidence.invalid/unsafe', dispatchEnvironment); + ? await linuxProtocolDispatch({ application, profile, link: WARM_TUNNEL, env: dispatchEnvironment, processGroups }) + : await macProtocolDispatch({ launchServices, link: WARM_TUNNEL, env: dispatchEnvironment }); + await waitForEvents(firstEvidence, ['desktop.deeplink.warm_tunnel_once'], first.child); + await dispatchDirect(application, profile.userData, WARM_OPEN, dispatchEnvironment, processGroups); + await waitForEvents(firstEvidence, ['desktop.deeplink.warm_open_once'], first.child); + await dispatchDirect(application, profile.userData, 'native-evidence-malformed', dispatchEnvironment, processGroups); + await waitForEvents(firstEvidence, ['desktop.deeplink.rejected_malformed'], first.child); await dispatchDirect( application, profile.userData, `propr://connect?api=https%3A%2F%2Ft-native-evidence.propr.dev%2F${'a'.repeat(2_100)}`, dispatchEnvironment, + processGroups, + ); + await waitForEvents(firstEvidence, ['desktop.deeplink.rejected_oversized'], first.child); + await dispatchDirect( + application, + profile.userData, + 'https://native-evidence.invalid/unsafe', + dispatchEnvironment, + processGroups, ); - await waitForExit(first); - await waitForEvents(firstEvidence, REQUIRED_FIRST_EVENTS, { exitCode: null }); + await waitForEvents(firstEvidence, ['desktop.deeplink.rejected_unsafe_scheme'], first.child); + await first.waitForSuccessfulExit(); + const requiredFirstEvents = target.platform === 'linux' + ? REQUIRED_FIRST_EVENTS.flatMap(event => event === 'desktop.native.secure_storage_enforced' + ? ['desktop.native.secure_storage_fallback_refused', event] + : [event]) + : REQUIRED_FIRST_EVENTS; + await waitForEvents(firstEvidence, requiredFirstEvents, { exitCode: null }); + await assertEvidenceOrdering(firstEvidence, requiredFirstEvents); await assertProfileAuthority(profile); const relaunchEnvironment = Object.freeze({ @@ -553,40 +1003,70 @@ const lifecycleForArtifact = async ({ target, kind, artifact, report }) => { '--propr-smoke-test', `--user-data-dir=${profile.userData}`, COLD_TUNNEL, - ], relaunchEnvironment, workRoot); - await waitForExit(relaunch); + ], relaunchEnvironment, workRoot, processGroups); + await relaunch.waitForSuccessfulExit(); + const relaunchEvidence = join(profile.userData, 'application.smoke-evidence.relaunch.jsonl'); await waitForEvents( - join(profile.userData, 'application.smoke-evidence.relaunch.jsonl'), + relaunchEvidence, REQUIRED_RELAUNCH_EVENTS, { exitCode: null }, ); + await assertEvidenceOrdering(relaunchEvidence, REQUIRED_RELAUNCH_EVENTS); await assertProfileAuthority(profile); if (await digest(artifact) !== beforeDigest) throw new Error('Native lifecycle mutated the staged artifact bytes'); await assertDefaultUserDataUntouched(target); - report.push({ kind, protocol, lifecycle: 'extract-or-mount-copy/launch/shutdown/relaunch/remove' }); - } finally { - await closeServer(profileApi.server).catch(() => undefined); - if (target.platform === 'darwin' && application) { - const launchServices = '/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister'; - await run(launchServices, ['-u', application.applicationRoot]).catch(() => undefined); - } - if (mounted) await run('/usr/bin/hdiutil', ['detach', mountRoot]).catch(() => undefined); - if (target.platform === 'linux' && application) { - await run('/usr/bin/sudo', ['/bin/rm', '-f', join(application.applicationRoot, 'chrome-sandbox')]).catch(() => undefined); - } - await removePrivateSmokeProfile(profile).catch(() => undefined); - await rm(installRoot, { recursive: true, force: true }); - await rm(mountRoot, { recursive: true, force: true }); - await rm(workRoot, { recursive: true, force: true }); - for (const path of [installRoot, profile.root]) { - try { - await stat(path); - throw new Error('Native uninstall/remove left an owned root behind'); - } catch (error) { - if (error?.code !== 'ENOENT') throw error; - } + report.push({ + coldDispatch: 'direct-argv (not OS protocol launch)', + kind, + lifecycle: 'extract-or-mount-copy/launch/shutdown/relaunch/remove', + protocol, + secureStorage: target.platform === 'linux' + ? 'fallback-only; plaintext refused; libsecret custody not exercised' + : 'OS-protected Keychain round-trip and deletion', + }); + } catch (error) { + primaryError = errorFrom(error, 'Native lifecycle operation failed'); + } + + const cleanupFailures = await processGroups.cleanup(); + const cleanup = async (label, operation) => { + try { + await operation(); + } catch (error) { + cleanupFailures.push({ label, error: errorFrom(error, 'Native lifecycle cleanup failed') }); } + }; + if (profileApi) await cleanup('profile-api', () => closeProfileApi(profileApi)); + if (launchServices?.registered) await cleanup('launchservices-unregister', () => launchServices.unregister()); + if (mountAuthority?.mounted) await cleanup('dmg-mount', () => mountAuthority.detach()); + if (sandboxPrepared && application) { + await cleanup('linux-sandbox', () => run('/usr/bin/sudo', [ + '/bin/rm', '-f', join(application.applicationRoot, 'chrome-sandbox'), + ])); + } + await cleanup('install-root', () => rm(installRoot, { recursive: true, force: true })); + await cleanup('install-postcondition', () => assertAbsent( + installRoot, + 'Native uninstall/remove left an owned install root behind', + )); + if (launchServices?.registered) await cleanup('launchservices-postcondition', () => launchServices.assertGone()); + if (!mountAuthority?.mounted) { + await cleanup('mount-root', () => rm(mountRoot, { recursive: true, force: true })); + await cleanup('mount-postcondition', () => assertAbsent(mountRoot, 'Native DMG mount root remained after detach')); + } + if (profile) { + await cleanup('profile-authority', () => removeAuthorizedProfile(profile)); + } + const blocksOuterRemoval = cleanupFailures.some(failure => [ + 'dmg-mount', + 'mount-postcondition', + 'profile-authority', + ].includes(failure.label)); + if (!blocksOuterRemoval) { + await cleanup('work-root', () => rm(workRoot, { recursive: true, force: true })); + await cleanup('work-postcondition', () => assertAbsent(workRoot, 'Native lifecycle work root remained after cleanup')); } + throwCombined(primaryError, cleanupFailures); }; export const runNativeArtifactLifecycle = async target => { @@ -604,8 +1084,8 @@ export const runNativeArtifactLifecycle = async target => { target: `${target.platform}-${target.arch}`, evidence: report, limitations: target.platform === 'linux' - ? 'ZIP has no OS launcher; its warm dispatch is direct. Package launchers use an isolated CI relocation.' - : 'Unsigned internal-RC evidence uses local LaunchServices only; signing, notarization, and Gatekeeper assessment are not claimed.', + ? 'Cold launch is direct argv. ZIP warm dispatch is direct. Package warm dispatch uses isolated XDG/GIO. Secure storage is fallback-only; libsecret custody is not exercised.' + : 'Cold launch is direct argv. Warm protocol evidence uses local LaunchServices. Unsigned internal-RC evidence does not claim signing, notarization, or Gatekeeper assessment.', })); }; diff --git a/apps/desktop/scripts/test-native-artifact-lifecycle.test.mjs b/apps/desktop/scripts/test-native-artifact-lifecycle.test.mjs index 636538bf4..dc3d9fe31 100644 --- a/apps/desktop/scripts/test-native-artifact-lifecycle.test.mjs +++ b/apps/desktop/scripts/test-native-artifact-lifecycle.test.mjs @@ -1,9 +1,24 @@ import assert from 'node:assert/strict'; -import { mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; +import { spawn } from 'node:child_process'; +import { chmod, mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, test } from 'node:test'; -import { assertArtifactSet, parseArguments } from './test-native-artifact-lifecycle.mjs'; +import { inspect } from 'node:util'; +import { + assertArtifactSet, + assertSafeExtractedTree, + closeProfileApi, + DmgMountAuthority, + extractDmg, + extractRpm, + LaunchServicesAuthority, + NativeLifecycleFailure, + OwnedProcessGroups, + parseArguments, + removeAuthorizedProfile, + waitForEvents, +} from './test-native-artifact-lifecycle.mjs'; describe('native staged artifact lifecycle authority', () => { test('accepts only the exact four native target coordinates', () => { @@ -33,11 +48,9 @@ describe('native staged artifact lifecycle authority', () => { try { await Promise.all(names.map(name => writeFile(join(directory, name), name))); assert.deepEqual(await assertArtifactSet(target), ['deb', 'rpm', 'zip']); - await writeFile(join(directory, 'foreign.zip'), 'foreign'); await assert.rejects(assertArtifactSet(target), /unexpected or duplicate identity/); await rm(join(directory, 'foreign.zip')); - await rm(join(directory, names[0])); await assert.rejects(assertArtifactSet(target), /canonical staged deb/); await symlink(join(directory, names[1]), join(directory, names[0])); @@ -46,4 +59,189 @@ describe('native staged artifact lifecycle authority', () => { await rm(directory, { recursive: true, force: true }); } }); + + test('cleans a live detached process group after evidence timeout', { skip: process.platform === 'win32' }, async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-native-process-')); + const groups = new OwnedProcessGroups(); + const child = spawn(process.execPath, ['-e', ` + const { spawn } = require('node:child_process'); + const child = spawn('/bin/sleep', ['30'], { stdio: 'ignore' }); + process.on('SIGTERM', () => child.once('close', () => process.exit(0))); + setInterval(() => undefined, 1000); + `], { + detached: true, + shell: false, + stdio: 'ignore', + }); + groups.track(child); + try { + await assert.rejects( + waitForEvents(join(directory, 'missing.jsonl'), ['never'], child, 60), + /evidence deadline/, + ); + assert.doesNotThrow(() => process.kill(-child.pid, 0)); + assert.deepEqual(await groups.cleanup(), []); + assert.throws(() => process.kill(-child.pid, 0), error => error?.code === 'ESRCH'); + } finally { + await groups.cleanup(); + await rm(directory, { recursive: true, force: true }); + } + }); + + for (const failurePoint of ['scan', 'copy']) { + test(`detaches and verifies a DMG when ${failurePoint} fails after attach`, async () => { + const calls = []; + const runCommand = async (file, args) => { + calls.push([file, ...args]); + if (args[0] === 'info') return { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) }; + if (failurePoint === 'copy' && file.endsWith('/ditto')) throw new Error('injected copy failure'); + return { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) }; + }; + const authority = new DmgMountAuthority('/private/mount', { runCommand }); + await assert.rejects(extractDmg({ + artifact: '/private/artifact.dmg', + installRoot: '/private/install', + mountAuthority: authority, + readDirectory: failurePoint === 'scan' + ? async () => { throw new Error('injected scan failure'); } + : async () => [{ name: 'ProPR.app', isDirectory: () => true }], + runCommand, + }), new RegExp(`injected ${failurePoint} failure`)); + assert.equal(authority.mounted, false); + assert.deepEqual(calls.map(call => call[1]), [ + 'attach', + ...(failurePoint === 'copy' ? ['/private/mount/ProPR.app'] : []), + 'detach', + 'info', + ]); + }); + } + + test('retains DMG authority and fails when detach cannot prove the mount absent', async () => { + const authority = new DmgMountAuthority('/private/mount', { + runCommand: async (_file, args) => ({ + stdout: Buffer.from(args[0] === 'info' ? '/dev/disk9 /private/mount\n' : ''), + stderr: Buffer.alloc(0), + }), + }); + authority.mounted = true; + await assert.rejects(authority.detach(), /dmg-mounted-postcondition/); + assert.equal(authority.mounted, true); + }); + + test('preserves a DMG primary failure without exposing it through cleanup diagnostics', async () => { + const privateFailure = new Error('scan failed at /private/profile with https://secret.invalid/token'); + const authority = new DmgMountAuthority('/private/mount', { + runCommand: async (_file, args) => ({ + stdout: Buffer.from(args[0] === 'info' ? '/dev/disk9 /private/mount\n' : ''), + stderr: Buffer.alloc(0), + }), + }); + await assert.rejects(extractDmg({ + artifact: '/private/artifact.dmg', + installRoot: '/private/install', + mountAuthority: authority, + readDirectory: async () => { throw privateFailure; }, + }), error => { + assert.ok(error instanceof NativeLifecycleFailure); + assert.equal(error.primaryError, privateFailure); + assert.match(error.message, /dmg-mount/); + assert.doesNotMatch(String(error), /private\/profile|secret\.invalid/); + assert.doesNotMatch(JSON.stringify(error), /private\/profile|secret\.invalid/); + assert.doesNotMatch(inspect(error), /private\/profile|secret\.invalid/); + return true; + }); + assert.equal(authority.mounted, true); + }); + + test('surfaces LaunchServices unregister failure and stale exact registration', async () => { + const applicationRoot = '/private/copied/ProPR Desktop.app'; + const unregisterFailure = new LaunchServicesAuthority(applicationRoot, {}, { + runCommand: async (_file, args) => { + if (args[0] === '-u') throw new Error('injected unregister failure'); + return { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) }; + }, + }); + unregisterFailure.registered = true; + await assert.rejects(unregisterFailure.unregister(), /injected unregister failure/); + + const stale = new LaunchServicesAuthority(applicationRoot, {}, { + runCommand: async () => ({ + stdout: Buffer.from(`path: ${applicationRoot}\n`), + stderr: Buffer.alloc(0), + }), + }); + stale.registered = true; + await assert.rejects(stale.assertGone(), /remained registered/); + assert.equal(stale.registered, true); + }); + + test('does not mask profile API or private-profile authority cleanup failures', async () => { + const server = { + listening: true, + address: () => ({ address: '127.0.0.1', family: 'IPv4', port: 1 }), + close: callback => callback(new Error('injected close failure')), + closeAllConnections: () => undefined, + }; + await assert.rejects( + closeProfileApi({ server, port: 1 }), + error => error instanceof NativeLifecycleFailure && /profile-api-close, profile-api-listening/.test(error.message), + ); + await assert.rejects(closeProfileApi({ + server: { + ...server, + close: () => undefined, + }, + port: 1, + }, { closeDeadline: 10 }), error => ( + error instanceof NativeLifecycleFailure + && /profile-api-close, profile-api-listening/.test(error.message) + )); + await assert.rejects(removeAuthorizedProfile({ root: '/private/profile' }, { + removeProfile: async () => { throw new Error('injected profile failure'); }, + inspectPath: async () => ({ isDirectory: () => true }), + }), error => error instanceof NativeLifecycleFailure && /profile-authority, profile-postcondition/.test(error.message)); + }); + + test('rejects escaping symlinks and symlinks to special files', { skip: process.platform === 'win32' }, async () => { + const parent = await mkdtemp(join(tmpdir(), 'propr-native-tree-')); + const root = join(parent, 'root'); + try { + await mkdir(root); + const outside = join(parent, 'outside target with spaces'); + await writeFile(outside, 'outside'); + await symlink(outside, join(root, 'escaping link')); + await assert.rejects(assertSafeExtractedTree(root), /escaping its install root/); + await rm(join(root, 'escaping link')); + + const fifo = join(root, 'owned fifo'); + const mkfifo = spawn('/usr/bin/mkfifo', [fifo], { shell: false, stdio: 'ignore' }); + const code = await new Promise(resolve => mkfifo.once('close', resolve)); + assert.equal(code, 0); + await symlink(fifo, join(root, 'fifo link')); + await assert.rejects(assertSafeExtractedTree(root), /symlink to an unsupported filesystem entry/); + } finally { + await rm(parent, { recursive: true, force: true }); + } + }); + + test('waits for a late rpm2cpio failure after extractor completion', { skip: process.platform === 'win32' }, async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-native-rpm-')); + const converter = join(directory, 'late-converter.sh'); + const extractor = join(directory, 'early-extractor.sh'); + try { + await writeFile(converter, '#!/bin/sh\nexec 1>&-\nsleep 0.15\nexit 29\n'); + await writeFile(extractor, '#!/bin/sh\ncat >/dev/null\nexit 0\n'); + await chmod(converter, 0o700); + await chmod(extractor, 0o700); + const started = Date.now(); + await assert.rejects( + extractRpm('fixture.rpm', directory, { converterFile: converter, extractorFile: extractor, timeout: 2_000 }), + /rpm2cpio failed with code 29/, + ); + assert.ok(Date.now() - started >= 100, 'extraction resolved before the converter reported its late failure'); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); }); diff --git a/apps/desktop/src/deep-link-delivery.test.ts b/apps/desktop/src/deep-link-delivery.test.ts index 9c2f81a67..6f8b8e91a 100644 --- a/apps/desktop/src/deep-link-delivery.test.ts +++ b/apps/desktop/src/deep-link-delivery.test.ts @@ -1,97 +1,134 @@ import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; import { describe, it } from 'node:test'; import { DeepLinkDelivery, type DeepLinkWindow } from './deep-link-delivery'; +import type { DesktopDeepLinkDelivery } from './shared/contract'; describe('desktop deep-link delivery', () => { - const createWindow = (sent: Array<{ channel: string; value: string }>): DeepLinkWindow => ({ + const createWindow = (sent: DesktopDeepLinkDelivery[]): DeepLinkWindow => ({ isDestroyed: () => false, webContents: { isLoading: () => false, - send: (channel, value) => sent.push({ channel, value }), + send: (_channel, value) => sent.push(value), }, }); + const tick = () => new Promise(resolve => setImmediate(resolve)); - it('queues links received after did-finish-load until the ready window is registered', () => { - const sent: Array<{ channel: string; value: string }> = []; + it('queues across the load boundary and waits for renderer consumption in order', async () => { + const sent: DesktopDeepLinkDelivery[] = []; + const consumed: string[] = []; + const delivery = new DeepLinkDelivery( + 'desktop:deep-link', + ['propr://connect?api=http%3A%2F%2Flocalhost%3A4000'], + value => { consumed.push(value); }, + ); const window = createWindow(sent); - const delivery = new DeepLinkDelivery('desktop:deep-link', ['propr://open?task=initial']); - - delivery.didFinishLoad(window); - delivery.deliver('propr://open?task=between'); - - assert.deepEqual(sent, []); - + delivery.deliver('propr://open?path=%2Ftasks'); delivery.setWindow(window); - assert.deepEqual(sent, [ - { channel: 'desktop:deep-link', value: 'propr://open?task=initial' }, - { channel: 'desktop:deep-link', value: 'propr://open?task=between' }, + assert.equal(sent.length, 1); + assert.deepEqual(consumed, []); + assert.equal(delivery.acknowledge(window, { + ...sent[0], + consumption: { kind: 'connect-confirmation', target: 'http://localhost:4000' }, + }), true); + await tick(); + assert.equal(sent.length, 2); + assert.deepEqual(consumed, ['propr://connect?api=http%3A%2F%2Flocalhost%3A4000']); + assert.equal(delivery.acknowledge(window, { + ...sent[1], + consumption: { kind: 'open-queued', target: '/tasks' }, + }), true); + await delivery.whenIdle(); + + assert.deepEqual(consumed, [ + 'propr://connect?api=http%3A%2F%2Flocalhost%3A4000', + 'propr://open?path=%2Ftasks', ]); }); - it('delivers a queued initial Connect URL before packaged smoke asserts it and only once', () => { - const main = readFileSync(new URL('./main.ts', import.meta.url), 'utf8'); - const preloadReady = main.indexOf("throw new Error('Desktop preload bridge was not exposed to the renderer')"); - const readyWindowRegistration = main.indexOf('deepLinkDelivery.setWindow(window);'); - const packagedSmokeStart = main.indexOf('const smokeProfileApiUrl ='); - assert.ok(preloadReady < readyWindowRegistration); - assert.ok(readyWindowRegistration < packagedSmokeStart); - assert.equal(main.match(/deepLinkDelivery\.setWindow\(/g)?.length, 1); - - const sent: Array<{ channel: string; value: string }> = []; - const window = createWindow(sent); - const connectUrl = 'propr://connect?api=https%3A%2F%2Fconnect.propr.dev'; - const delivery = new DeepLinkDelivery('desktop:deep-link', [connectUrl]); - - delivery.didFinishLoad(window); - assert.deepEqual(sent, []); - - delivery.setWindow(window); - const assertPackagedSmokeDeepLink = () => { - assert.deepEqual(sent.filter(({ value }) => value === connectUrl), [ - { channel: 'desktop:deep-link', value: connectUrl }, - ]); - }; - assertPackagedSmokeDeepLink(); - - delivery.didFinishLoad(window); - delivery.setWindow(window); - - assert.equal(sent.filter(({ value }) => value === connectUrl).length, 1); - }); - - it('coalesces duplicate OS notifications without swallowing a later intentional activation', () => { - const sent: Array<{ channel: string; value: string }> = []; - const delivered: string[] = []; + it('rejects duplicate delivery and duplicate or out-of-order acknowledgements', async () => { + const sent: DesktopDeepLinkDelivery[] = []; + const consumed: string[] = []; let now = 1_000; const link = 'propr://open?path=%2Ftasks'; const delivery = new DeepLinkDelivery( 'desktop:deep-link', [], - value => delivered.push(value), + value => { consumed.push(value); }, + error => { throw error; }, () => now, 1_000, ); - delivery.setWindow(createWindow(sent)); + const window = createWindow(sent); + delivery.setWindow(window); assert.equal(delivery.deliver(link), true); assert.equal(delivery.deliver(link), false); + assert.equal(sent.length, 1); + assert.equal(delivery.acknowledge(window, { + deliveryId: sent[0].deliveryId + 1, + url: link, + consumption: { kind: 'open-queued', target: '/tasks' }, + }), false); + const acknowledgement = { + ...sent[0], + consumption: { kind: 'open-queued' as const, target: '/tasks' }, + }; + assert.equal(delivery.acknowledge(window, acknowledgement), true); + assert.equal(delivery.acknowledge(window, acknowledgement), false); + await delivery.whenIdle(); + assert.deepEqual(consumed, [link]); + now += 1_001; assert.equal(delivery.deliver(link), true); + await tick(); + assert.equal(sent.length, 2); + assert.equal(delivery.acknowledge(window, { + ...sent[1], + consumption: { kind: 'open-queued', target: '/tasks' }, + }), true); + await delivery.whenIdle(); + assert.deepEqual(consumed, [link, link]); + }); - assert.deepEqual(sent.map(item => item.value), [link, link]); - assert.deepEqual(delivered, [link, link]); + it('fails closed when the renderer does not acknowledge consumption', async () => { + const sent: DesktopDeepLinkDelivery[] = []; + let failure: Error | undefined; + const delivery = new DeepLinkDelivery( + 'desktop:deep-link', + [], + undefined, + error => { failure = error; }, + Date.now, + 1_000, + 20, + ); + delivery.setWindow(createWindow(sent)); + delivery.deliver('propr://open?path=%2Ftasks'); + await delivery.whenIdle(); + assert.equal(sent.length, 1); + assert.match(failure?.message ?? '', /acknowledgement deadline/); }); - it('deduplicates a cold link reported through both argv and open-url', () => { - const sent: Array<{ channel: string; value: string }> = []; + it('deduplicates a cold link reported through argv and open-url before delivery', async () => { + const sent: DesktopDeepLinkDelivery[] = []; const link = 'propr://connect?api=https%3A%2F%2Ft-native-evidence.propr.dev'; - const delivery = new DeepLinkDelivery('desktop:deep-link', [link], undefined, () => 10); - + const delivery = new DeepLinkDelivery( + 'desktop:deep-link', + [link], + undefined, + undefined, + () => 10, + ); + const window = createWindow(sent); assert.equal(delivery.deliver(link), false); - delivery.setWindow(createWindow(sent)); - - assert.deepEqual(sent, [{ channel: 'desktop:deep-link', value: link }]); + delivery.setWindow(window); + assert.equal(sent.length, 1); + delivery.acknowledge(window, { + ...sent[0], + consumption: { kind: 'connect-confirmation', target: 'https://t-native-evidence.propr.dev' }, + }); + await delivery.whenIdle(); + assert.equal(sent.length, 1); }); }); diff --git a/apps/desktop/src/deep-link-delivery.ts b/apps/desktop/src/deep-link-delivery.ts index a34f91d13..acd3f76dd 100644 --- a/apps/desktop/src/deep-link-delivery.ts +++ b/apps/desktop/src/deep-link-delivery.ts @@ -1,8 +1,14 @@ +import type { + DesktopDeepLinkAcknowledgement, + DesktopDeepLinkConsumption, + DesktopDeepLinkDelivery, +} from './shared/contract'; + export interface DeepLinkWindow { isDestroyed(): boolean; webContents: { isLoading(): boolean; - send(channel: string, value: string): void; + send(channel: string, value: DesktopDeepLinkDelivery): void; }; } @@ -10,16 +16,33 @@ export interface DeepLinkWindow { export class DeepLinkDelivery { private window: TWindow | null = null; private readonly recentlyAccepted = new Map(); + private deliveryId = 0; + private draining = false; + private active: { + acknowledged: boolean; + delivery: DesktopDeepLinkDelivery; + resolve: (consumption: DesktopDeepLinkConsumption) => void; + timer: ReturnType; + window: TWindow; + } | null = null; + private readonly idleWaiters = new Set<() => void>(); constructor( private readonly channel: string, private readonly pending: string[] = [], - private readonly delivered: (value: string) => void = () => undefined, + private readonly delivered: ( + value: string, + consumption: DesktopDeepLinkConsumption, + window: TWindow, + ) => void | Promise = () => undefined, + private readonly failed: (error: Error) => void = () => undefined, private readonly now: () => number = Date.now, private readonly duplicateWindowMs = 1_000, + private readonly acknowledgementTimeoutMs = 5_000, ) { - if (!Number.isFinite(duplicateWindowMs) || duplicateWindowMs < 0) { - throw new Error('Desktop deep-link duplicate window must be non-negative'); + if (!Number.isFinite(duplicateWindowMs) || duplicateWindowMs < 0 + || !Number.isFinite(acknowledgementTimeoutMs) || acknowledgementTimeoutMs <= 0) { + throw new Error('Desktop deep-link timing configuration is invalid'); } const uniquePending = [...new Set(pending)]; pending.splice(0, pending.length, ...uniquePending); @@ -35,11 +58,8 @@ export class DeepLinkDelivery { for (const [candidate, time] of this.recentlyAccepted) { if (acceptedAt - time > this.duplicateWindowMs) this.recentlyAccepted.delete(candidate); } - if (!this.window || this.window.isDestroyed() || this.window.webContents.isLoading()) { - this.pending.push(value); - return true; - } - this.send(this.window, value); + this.pending.push(value); + void this.drain(); return true; } @@ -49,21 +69,83 @@ export class DeepLinkDelivery { setWindow(window: TWindow): void { this.window = window; - this.flush(window); + void this.drain(); } clearWindow(window: TWindow): void { if (this.window === window) this.window = null; } - private flush(window: TWindow): void { - if (window.isDestroyed() || window.webContents.isLoading()) return; - const linksToDeliver = this.pending.splice(0); - linksToDeliver.forEach(value => this.send(window, value)); + acknowledge(window: TWindow, acknowledgement: DesktopDeepLinkAcknowledgement): boolean { + if (!this.active || this.active.acknowledged || this.active.window !== window + || acknowledgement.deliveryId !== this.active.delivery.deliveryId + || acknowledgement.url !== this.active.delivery.url) return false; + this.active.acknowledged = true; + clearTimeout(this.active.timer); + this.active.resolve(acknowledgement.consumption); + return true; + } + + acknowledgeSender(sender: unknown, acknowledgement: DesktopDeepLinkAcknowledgement): boolean { + if (!this.active || this.active.window.webContents !== sender) return false; + return this.acknowledge(this.active.window, acknowledgement); } - private send(window: TWindow, value: string): void { - window.webContents.send(this.channel, value); - this.delivered(value); + whenIdle(): Promise { + if (!this.draining && !this.active && this.pending.length === 0) return Promise.resolve(); + return new Promise(resolve => this.idleWaiters.add(resolve)); + } + + private flush(_window: TWindow): void { + void this.drain(); + } + + private async drain(): Promise { + if (this.draining) return; + this.draining = true; + try { + while (this.pending.length > 0) { + const window = this.window; + if (!window || window.isDestroyed() || window.webContents.isLoading()) return; + const value = this.pending.shift(); + if (value === undefined) return; + const delivery = { deliveryId: ++this.deliveryId, url: value }; + let resolveAcknowledgement!: (value: DesktopDeepLinkConsumption) => void; + let rejectAcknowledgement!: (error: Error) => void; + const acknowledgement = new Promise((resolve, reject) => { + resolveAcknowledgement = resolve; + rejectAcknowledgement = reject; + }); + const timer = setTimeout( + () => rejectAcknowledgement(new Error('Desktop renderer deep-link acknowledgement deadline expired')), + this.acknowledgementTimeoutMs, + ); + this.active = { + acknowledged: false, + delivery, + resolve: resolveAcknowledgement, + timer, + window, + }; + window.webContents.send(this.channel, delivery); + try { + const consumption = await acknowledgement; + await this.delivered(value, consumption, window); + } catch (error) { + this.pending.splice(0); + this.failed(error instanceof Error ? error : new Error('Desktop renderer deep-link acknowledgement failed')); + return; + } finally { + clearTimeout(timer); + this.active = null; + } + } + } finally { + this.draining = false; + if (!this.active && this.pending.length === 0) { + this.idleWaiters.forEach(resolve => resolve()); + this.idleWaiters.clear(); + } + } } } diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index 93245534b..1932d2b1c 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -6,6 +6,7 @@ import type { LocalLifecycleController } from './lifecycle'; import type { ProfileStore } from './profile-store'; import { isSafeExternalUrl, isTrustedRendererUrl } from './security'; import { IPC_CHANNELS } from './shared/contract'; +import type { DesktopDeepLinkAcknowledgement } from './shared/contract'; interface RegisterIpcOptions { app: App; @@ -16,6 +17,7 @@ interface RegisterIpcOptions { desktopSession: Session; devServerUrl: string | undefined; packagedRendererUrl: string; + acknowledgeDeepLink: (event: IpcMainInvokeEvent, acknowledgement: DesktopDeepLinkAcknowledgement) => boolean; } type Handler = (event: IpcMainInvokeEvent, ...args: any[]) => unknown; @@ -47,6 +49,20 @@ export const registerIpcHandlers = (options: RegisterIpcOptions): void => { arch: process.arch, packaged: options.app.isPackaged, })); + handle(IPC_CHANNELS.deepLinkAcknowledgement, (event, acknowledgement) => { + if (!acknowledgement || typeof acknowledgement !== 'object' + || !Number.isSafeInteger(acknowledgement.deliveryId) || acknowledgement.deliveryId <= 0 + || typeof acknowledgement.url !== 'string' + || !acknowledgement.consumption || typeof acknowledgement.consumption !== 'object' + || !['connect-confirmation', 'open-queued', 'open-navigated'].includes(acknowledgement.consumption.kind) + || typeof acknowledgement.consumption.target !== 'string') { + throw new Error('Invalid desktop deep-link acknowledgement'); + } + if (!options.acknowledgeDeepLink(event, acknowledgement)) { + throw new Error('Unexpected desktop deep-link acknowledgement'); + } + return undefined; + }); handle(IPC_CHANNELS.authLogout, (_event, apiBaseUrl) => logoutDesktopSession(options.desktopSession, apiBaseUrl)); handle(IPC_CHANNELS.openExternal, async (_event, value: unknown) => { if (typeof value !== 'string' || !isSafeExternalUrl(value)) throw new Error('External URL is not allowed'); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 036706663..c1f6aa187 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -19,6 +19,7 @@ import { validatedDevServerUrl, } from './security'; import { DESKTOP_PROTOCOL, IPC_CHANNELS } from './shared/contract'; +import type { DesktopDeepLinkConsumption } from './shared/contract'; import { checkForSignedUpdates } from './signed-updates'; import { authorizePackagedSmokeTest } from './smoke-test-authorization'; import { createPackagedSmokeEvidenceSink } from './smoke-test-evidence'; @@ -77,6 +78,7 @@ try { } const packagedSmokeTest = packagedSmokeUserDataDirectory !== null; let mainWindow: BrowserWindow | null = null; +let nativeSmokeWindow: BrowserWindow | null = null; const initialDeepLink = deepLinkFromArguments(process.argv); const nativeObservedEvents = new Set(); let nativeRendererReady = false; @@ -104,14 +106,44 @@ const nativeEventForDeliveredLink = (value: string): string | null => { } return null; }; +const assertNativeRendererConsumption = ( + value: string, + consumption: DesktopDeepLinkConsumption, + window: BrowserWindow, +): void => { + const expected = value === NATIVE_COLD_MANUAL_LINK + ? { kind: 'connect-confirmation', target: 'http://localhost:44111' } + : value === NATIVE_COLD_TUNNEL_LINK + ? { kind: 'connect-confirmation', target: 'https://t-native-relaunch.propr.dev' } + : value === NATIVE_WARM_MANUAL_LINK + ? { kind: 'connect-confirmation', target: 'http://127.0.0.1:44112' } + : value === NATIVE_WARM_TUNNEL_LINK + ? { kind: 'connect-confirmation', target: 'https://t-native-evidence.propr.dev' } + : value === NATIVE_WARM_OPEN_LINK + ? { kind: 'open-queued', target: '/tasks?status=open' } + : null; + if (!expected) return; + if (consumption.kind !== expected.kind || consumption.target !== expected.target) { + throw new Error('Native renderer deep-link acknowledgement did not prove the intended state'); + } + if ([NATIVE_WARM_MANUAL_LINK, NATIVE_WARM_TUNNEL_LINK, NATIVE_WARM_OPEN_LINK].includes(value) + && nativeSmokeWindow !== window) { + throw new Error('Native warm deep link did not reach the already-running renderer'); + } +}; const deepLinkDelivery = new DeepLinkDelivery( IPC_CHANNELS.deepLink, initialDeepLink ? [initialDeepLink] : [], - value => { + (value, consumption, window) => { + assertNativeRendererConsumption(value, consumption, window); const event = nativeEventForDeliveredLink(value); if (event) recordNativeEvent(event); maybeCompleteNativeFirstLaunch(); }, + error => { + log('error', 'desktop.app.start_failed', { error }); + app.exit(1); + }, ); let logger: DesktopLogger | null = null; let shutdownStarted = false; @@ -381,7 +413,9 @@ const createMainWindow = async (): Promise => { if (preloadBridgeExposed !== true) { throw new Error('Desktop preload bridge was not exposed to the renderer'); } + if (nativeSmokePhase && !nativeSmokeWindow) nativeSmokeWindow = window; deepLinkDelivery.setWindow(window); + if (nativeSmokePhase) await deepLinkDelivery.whenIdle(); const smokeProfileApiUrl = process.env.PROPR_DESKTOP_SMOKE_PROFILE_API_URL; if (packagedSmokeTest && smokeProfileApiUrl) { const normalizedSmokeApiUrl = normalizeApiBaseUrl(smokeProfileApiUrl); @@ -441,7 +475,13 @@ const createMainWindow = async (): Promise => { const storage = nativeProfiles?.security(); const credentialWrite = await nativeProfiles?.writeCredential('native-local', 'native-custody-probe'); if (!storage || !credentialWrite) throw new Error('Native secure-storage custody probe did not run'); - if (storage.available) { + if (process.platform === 'linux') { + if (storage.available || credentialWrite.stored + || (await nativeProfiles?.readCredential('native-local'))?.available !== false) { + throw new Error('Native Linux fallback-only proof unexpectedly claimed libsecret custody'); + } + recordNativeEvent('desktop.native.secure_storage_fallback_refused'); + } else if (storage.available) { if (storage.backend === 'basic_text' || !credentialWrite.stored || (await nativeProfiles?.readCredential('native-local'))?.value !== 'native-custody-probe') { throw new Error('Native secure-storage custody probe did not use OS encryption'); @@ -582,6 +622,8 @@ if (!hasSingleInstanceLock) { desktopSession: session.defaultSession, devServerUrl, packagedRendererUrl, + acknowledgeDeepLink: (event, acknowledgement) => + deepLinkDelivery.acknowledgeSender(event.sender, acknowledgement), }); app.on('before-quit', event => { diff --git a/apps/desktop/src/preload-bridge.test.ts b/apps/desktop/src/preload-bridge.test.ts index 81db36bef..32a7e03b0 100644 --- a/apps/desktop/src/preload-bridge.test.ts +++ b/apps/desktop/src/preload-bridge.test.ts @@ -5,18 +5,18 @@ import { IPC_CHANNELS } from './shared/contract'; class FakeIpc implements PreloadIpc { readonly invocations: Array<{ channel: string; args: unknown[] }> = []; - readonly listeners = new Map void>(); + readonly listeners = new Map void>(); async invoke(channel: string, ...args: unknown[]): Promise { this.invocations.push({ channel, args }); return undefined; } - on(channel: string, listener: (event: unknown, value: string) => void): void { + on(channel: string, listener: (event: unknown, value: unknown) => void): void { this.listeners.set(channel, listener); } - removeListener(channel: string, listener: (event: unknown, value: string) => void): void { + removeListener(channel: string, listener: (event: unknown, value: unknown) => void): void { if (this.listeners.get(channel) === listener) this.listeners.delete(channel); } } @@ -53,9 +53,23 @@ describe('desktop preload bridge', () => { const ipc = new FakeIpc(); const bridge = createDesktopBridge(ipc); const received: string[] = []; - const unsubscribe = bridge.app.onDeepLink(value => received.push(value)); - ipc.listeners.get(IPC_CHANNELS.deepLink)?.({ sender: 'must-not-leak' }, 'propr://open?path=%2Ftasks'); + const unsubscribe = bridge.app.onDeepLink(value => { + received.push(value); + return { kind: 'open-queued', target: '/tasks' }; + }); + ipc.listeners.get(IPC_CHANNELS.deepLink)?.({ sender: 'must-not-leak' }, { + deliveryId: 1, + url: 'propr://open?path=%2Ftasks', + }); assert.deepEqual(received, ['propr://open?path=%2Ftasks']); + assert.deepEqual(ipc.invocations, [{ + channel: IPC_CHANNELS.deepLinkAcknowledgement, + args: [{ + deliveryId: 1, + url: 'propr://open?path=%2Ftasks', + consumption: { kind: 'open-queued', target: '/tasks' }, + }], + }]); unsubscribe(); assert.equal(ipc.listeners.has(IPC_CHANNELS.deepLink), true); }); @@ -66,11 +80,16 @@ describe('desktop preload bridge', () => { const receiveDeepLink = ipc.listeners.get(IPC_CHANNELS.deepLink); assert.ok(receiveDeepLink, 'preload must register its IPC listener eagerly'); - receiveDeepLink({}, 'propr://connect?api=http%3A%2F%2Flocalhost%3A4000'); - receiveDeepLink({}, 'propr://open?path=%2Ftasks'); + receiveDeepLink({}, { deliveryId: 1, url: 'propr://connect?api=http%3A%2F%2Flocalhost%3A4000' }); + receiveDeepLink({}, { deliveryId: 2, url: 'propr://open?path=%2Ftasks' }); const received: string[] = []; - bridge.app.onDeepLink(value => received.push(value)); + bridge.app.onDeepLink(value => { + received.push(value); + return value.includes('connect') + ? { kind: 'connect-confirmation', target: 'http://localhost:4000' } + : { kind: 'open-queued', target: '/tasks' }; + }); assert.deepEqual(received, [ 'propr://connect?api=http%3A%2F%2Flocalhost%3A4000', 'propr://open?path=%2Ftasks', diff --git a/apps/desktop/src/preload-bridge.ts b/apps/desktop/src/preload-bridge.ts index 3bba8300e..917635ede 100644 --- a/apps/desktop/src/preload-bridge.ts +++ b/apps/desktop/src/preload-bridge.ts @@ -1,24 +1,47 @@ -import type { DesktopBridge } from './shared/contract'; +import type { + DesktopBridge, + DesktopDeepLinkAcknowledgement, + DesktopDeepLinkConsumption, + DesktopDeepLinkDelivery, +} from './shared/contract'; import { IPC_CHANNELS } from './shared/contract'; export interface PreloadIpc { invoke(channel: string, ...args: unknown[]): Promise; - on(channel: string, listener: (event: unknown, value: string) => void): void; - removeListener(channel: string, listener: (event: unknown, value: string) => void): void; + on(channel: string, listener: (event: unknown, value: unknown) => void): void; + removeListener(channel: string, listener: (event: unknown, value: unknown) => void): void; } const invoke = (ipc: PreloadIpc, channel: string, ...args: unknown[]): Promise => ipc.invoke(channel, ...args) as Promise; export const createDesktopBridge = (ipc: PreloadIpc): DesktopBridge => { - const deepLinkListeners = new Set<(url: string) => void>(); - const pendingDeepLinks: string[] = []; + const deepLinkListeners = new Set<(url: string) => DesktopDeepLinkConsumption | null>(); + const pendingDeepLinks: DesktopDeepLinkDelivery[] = []; + const isDelivery = (value: unknown): value is DesktopDeepLinkDelivery => Boolean( + value && typeof value === 'object' + && Number.isSafeInteger((value as DesktopDeepLinkDelivery).deliveryId) + && (value as DesktopDeepLinkDelivery).deliveryId > 0 + && typeof (value as DesktopDeepLinkDelivery).url === 'string', + ); + const consume = (delivery: DesktopDeepLinkDelivery): void => { + const acknowledgements = [...deepLinkListeners] + .map(listener => listener(delivery.url)) + .filter((value): value is DesktopDeepLinkConsumption => value !== null); + if (acknowledgements.length !== 1) return; + const acknowledgement: DesktopDeepLinkAcknowledgement = { + ...delivery, + consumption: acknowledgements[0], + }; + void invoke(ipc, IPC_CHANNELS.deepLinkAcknowledgement, acknowledgement).catch(() => undefined); + }; ipc.on(IPC_CHANNELS.deepLink, (_event, value) => { + if (!isDelivery(value)) return; if (deepLinkListeners.size === 0) { pendingDeepLinks.push(value); return; } - deepLinkListeners.forEach(listener => listener(value)); + consume(value); }); const bridge: DesktopBridge = { @@ -26,7 +49,7 @@ export const createDesktopBridge = (ipc: PreloadIpc): DesktopBridge => { getMetadata: () => invoke(ipc, IPC_CHANNELS.appMetadata), onDeepLink: (listener) => { deepLinkListeners.add(listener); - pendingDeepLinks.splice(0).forEach(value => listener(value)); + pendingDeepLinks.splice(0).forEach(consume); return () => deepLinkListeners.delete(listener); }, }, diff --git a/apps/desktop/src/shared/contract.ts b/apps/desktop/src/shared/contract.ts index f34d23298..95d4e32ed 100644 --- a/apps/desktop/src/shared/contract.ts +++ b/apps/desktop/src/shared/contract.ts @@ -17,8 +17,23 @@ export const IPC_CHANNELS = Object.freeze({ lifecycleStop: 'desktop:lifecycle-stop', lifecycleRestart: 'desktop:lifecycle-restart', deepLink: 'desktop:deep-link', + deepLinkAcknowledgement: 'desktop:deep-link-acknowledgement', } as const); +export interface DesktopDeepLinkDelivery { + deliveryId: number; + url: string; +} + +export type DesktopDeepLinkConsumption = { + kind: 'connect-confirmation' | 'open-queued' | 'open-navigated'; + target: string; +}; + +export interface DesktopDeepLinkAcknowledgement extends DesktopDeepLinkDelivery { + consumption: DesktopDeepLinkConsumption; +} + export type DesktopPlatform = 'aix' | 'android' | 'darwin' | 'freebsd' | 'haiku' | 'linux' | 'openbsd' | 'sunos' | 'win32' | 'cygwin' | 'netbsd'; @@ -80,7 +95,7 @@ export type LocalLifecycleOperationResult = export interface DesktopBridge { app: { getMetadata(): Promise; - onDeepLink(listener: (url: string) => void): () => void; + onDeepLink(listener: (url: string) => DesktopDeepLinkConsumption | null): () => void; }; auth: { logout(apiBaseUrl: string): Promise; diff --git a/apps/desktop/src/smoke-test-evidence.ts b/apps/desktop/src/smoke-test-evidence.ts index d446aa7b9..b4c278544 100644 --- a/apps/desktop/src/smoke-test-evidence.ts +++ b/apps/desktop/src/smoke-test-evidence.ts @@ -32,6 +32,7 @@ export const NATIVE_SMOKE_EVIDENCE_EVENTS = [ 'desktop.native.profile_fresh', 'desktop.native.profile_preserved', 'desktop.native.secure_storage_enforced', + 'desktop.native.secure_storage_fallback_refused', 'desktop.deeplink.cold_manual_once', 'desktop.deeplink.cold_tunnel_once', 'desktop.deeplink.warm_manual_once', diff --git a/propr-ui/src/desktop-deep-link.test.ts b/propr-ui/src/desktop-deep-link.test.ts index b431da2ff..6067aeb71 100644 --- a/propr-ui/src/desktop-deep-link.test.ts +++ b/propr-ui/src/desktop-deep-link.test.ts @@ -6,7 +6,10 @@ describe('desktop open deep-link navigation', () => { const navigate = vi.fn(); const navigation = new DesktopDeepLinkNavigation(navigate); - expect(navigation.receive('propr://open?path=%2Ftasks')).toBe(true); + expect(navigation.receiveWithState('propr://open?path=%2Ftasks')).toEqual({ + path: '/tasks', + state: 'queued', + }); expect(navigate).not.toHaveBeenCalled(); navigation.setDashboardReady(); @@ -30,7 +33,10 @@ describe('desktop open deep-link navigation', () => { const navigation = new DesktopDeepLinkNavigation(navigate); navigation.setDashboardReady(); - expect(navigation.receive('propr://open?path=%2Ftasks%3Fstatus%3Dopen%23recent')).toBe(true); + expect(navigation.receiveWithState('propr://open?path=%2Ftasks%3Fstatus%3Dopen%23recent')).toEqual({ + path: '/tasks?status=open#recent', + state: 'navigated', + }); expect(navigate).toHaveBeenCalledWith('/tasks?status=open#recent'); }); diff --git a/propr-ui/src/desktop-deep-link.ts b/propr-ui/src/desktop-deep-link.ts index 6972698d1..3911498c4 100644 --- a/propr-ui/src/desktop-deep-link.ts +++ b/propr-ui/src/desktop-deep-link.ts @@ -7,12 +7,16 @@ export class DesktopDeepLinkNavigation { constructor(private readonly navigate: (path: string) => void) {} - receive(value: string): boolean { + receiveWithState(value: string): { path: string; state: 'queued' | 'navigated' } | null { const path = dashboardPathFromDeepLink(value); - if (!path) return false; + if (!path) return null; if (this.dashboardReady) this.navigate(path); else this.pendingPaths.push(path); - return true; + return { path, state: this.dashboardReady ? 'navigated' : 'queued' }; + } + + receive(value: string): boolean { + return this.receiveWithState(value) !== null; } setDashboardReady(): void { diff --git a/propr-ui/src/desktop.tsx b/propr-ui/src/desktop.tsx index 7bfee062f..c81d64104 100644 --- a/propr-ui/src/desktop.tsx +++ b/propr-ui/src/desktop.tsx @@ -166,13 +166,21 @@ export const DesktopRoot = () => { const deepLink = new URL(value); if (deepLink.hostname === 'connect') { const apiUrl = deepLink.searchParams.get('api'); - if (apiUrl) setInitialApiUrl(apiUrl); + if (apiUrl) { + setInitialApiUrl(apiUrl); + return { kind: 'connect-confirmation', target: apiUrl }; + } } else if (deepLink.hostname === 'open') { - deepLinkNavigation.receive(value); + const result = deepLinkNavigation.receiveWithState(value); + if (result) return { + kind: result.state === 'queued' ? 'open-queued' : 'open-navigated', + target: result.path, + }; } } catch { // Main validates protocol input; ignore malformed values defensively. } + return null; }); void Promise.all([bridge.app.getMetadata(), bridge.storage.security(), bridge.profiles.list()]) .then(async ([appMetadata, storageSecurity, profiles]) => { From 90b86106e49d4c1e92ef00ddf36801a254ee37f3 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:31:10 +0000 Subject: [PATCH 3/5] feat(ai): Implemented the complete scoped macOS/Linux/shared follow-up without committing. Implemented the complete scoped macOS/Linux/shared follow-up without committing. Key changes: - Production deep-link ACK failures now emit fixed, non-secret diagnostics without exiting or crashing; native artifact smoke remains fatal. - LaunchServices unregister and exact absence verification now precede copied-app deletion. Failures retain the install/work root. - Process groups receive a bounded natural drain, with zombie-aware inspection and TERM/KILL cleanup for genuine leaks. - Native lifecycle failures expose fixed operation stages without paths, URLs, credentials, or process output. - macOS native children retain the validated runner `HOME` for Keychain access while preserving isolated `--user-data-dir`, permissions, absence checks, and Keychain round-trip/deletion assertions. - Added focused adversarial tests for each correction. Verification: - Desktop suite: 207 tests, 201 passed, 6 platform skips. - Focused lifecycle/environment suite: 28/28 passed. - ACK policy tests: 3/3 passed. - Desktop typecheck passed. - `git diff --check` passed. - No Windows, workflow, lockfile, signing, release-profile, or artifact-definition changes. The four architecture-specific native gates require post-commit GitHub macOS/Linux runners and staged artifacts, so they could not be executed truthfully from this uncommitted Linux worktree. PR: #2085 Comment by: @integry (ID: 5516559539) Model: gpt-5.6-sol --- .../scripts/packaged-smoke-support.mjs | 16 +- .../scripts/packaged-smoke-support.test.mjs | 37 ++- .../test-native-artifact-lifecycle.mjs | 286 +++++++++++++++--- .../test-native-artifact-lifecycle.test.mjs | 129 ++++++++ .../src/deep-link-failure-policy.test.ts | 49 +++ apps/desktop/src/deep-link-failure-policy.ts | 29 ++ apps/desktop/src/main.ts | 14 +- apps/desktop/src/smoke-test-evidence.ts | 3 + 8 files changed, 523 insertions(+), 40 deletions(-) create mode 100644 apps/desktop/src/deep-link-failure-policy.test.ts create mode 100644 apps/desktop/src/deep-link-failure-policy.ts diff --git a/apps/desktop/scripts/packaged-smoke-support.mjs b/apps/desktop/scripts/packaged-smoke-support.mjs index 86ade3ef3..87b151670 100644 --- a/apps/desktop/scripts/packaged-smoke-support.mjs +++ b/apps/desktop/scripts/packaged-smoke-support.mjs @@ -294,6 +294,7 @@ export const createSmokeChildEnvironment = async ({ profileApiUrl, parentEnvironment = process.env, inspectPath = lstat, + preserveMacosKeychainContext = false, }) => { if (!profile || !createdProfiles.has(profile)) { throw new Error('Packaged smoke child environment rejected an unknown profile'); @@ -330,8 +331,21 @@ export const createSmokeChildEnvironment = async ({ }); } if (platform === 'darwin') { + let home = profile.home; + if (preserveMacosKeychainContext) { + const runnerHome = parentEnvironment.HOME; + if (typeof runnerHome !== 'string' || runnerHome.length > 4096 + || !isAbsolute(runnerHome) || resolve(runnerHome) !== runnerHome) { + throw new Error('Packaged smoke macOS Keychain home is invalid'); + } + const homeStats = await inspectPath(runnerHome); + if (!homeStats.isDirectory() || homeStats.isSymbolicLink()) { + throw new Error('Packaged smoke macOS Keychain home is invalid'); + } + home = runnerHome; + } return Object.freeze({ - HOME: profile.home, + HOME: home, ...triggers, TEMP: profile.temporary, TMP: profile.temporary, diff --git a/apps/desktop/scripts/packaged-smoke-support.test.mjs b/apps/desktop/scripts/packaged-smoke-support.test.mjs index 93e2d3e91..0927b1de6 100644 --- a/apps/desktop/scripts/packaged-smoke-support.test.mjs +++ b/apps/desktop/scripts/packaged-smoke-support.test.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { chmod, readFile, writeFile } from 'node:fs/promises'; +import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { basename, join, relative } from 'node:path'; import { describe, test } from 'node:test'; @@ -103,6 +103,41 @@ describe('packaged smoke native window layout', () => { }); describe('packaged smoke child environment', () => { + test('preserves a validated runner HOME only for an explicit native macOS Keychain context', async () => { + const profile = await createPrivateSmokeProfile(tmpdir()); + const runnerHome = await mkdtemp(join(tmpdir(), 'propr-runner-home-')); + try { + const isolated = await createSmokeChildEnvironment({ + platform: 'darwin', + profile, + profileApiUrl: 'http://127.0.0.1:43123', + parentEnvironment: { HOME: runnerHome }, + }); + assert.equal(isolated.HOME, profile.home); + + const keychainEnabled = await createSmokeChildEnvironment({ + platform: 'darwin', + profile, + profileApiUrl: 'http://127.0.0.1:43123', + parentEnvironment: { HOME: runnerHome }, + preserveMacosKeychainContext: true, + }); + assert.equal(keychainEnabled.HOME, runnerHome); + assert.equal(keychainEnabled.TMPDIR, profile.temporary); + + await assert.rejects(createSmokeChildEnvironment({ + platform: 'darwin', + profile, + profileApiUrl: 'http://127.0.0.1:43123', + parentEnvironment: { HOME: 'relative-home' }, + preserveMacosKeychainContext: true, + }), /Keychain home is invalid/); + } finally { + await removePrivateSmokeProfile(profile); + await rm(runnerHome, { recursive: true, force: true }); + } + }); + test('passes only platform launch inputs and private profile paths from a hostile parent', async () => { const parent = await createPrivateSmokeProfile(tmpdir()); const xAuthority = join(parent.root, 'Xauthority'); diff --git a/apps/desktop/scripts/test-native-artifact-lifecycle.mjs b/apps/desktop/scripts/test-native-artifact-lifecycle.mjs index f010a73ff..4e671062d 100644 --- a/apps/desktop/scripts/test-native-artifact-lifecycle.mjs +++ b/apps/desktop/scripts/test-native-artifact-lifecycle.mjs @@ -47,6 +47,8 @@ const REQUIRED_FIRST_EVENTS = [ 'desktop.smoke.authorized', 'desktop.native.identity_verified', 'desktop.deeplink.cold_manual_once', + 'desktop.native.secure_storage_probe.started', + 'desktop.native.secure_storage_probe.completed', 'desktop.native.secure_storage_enforced', 'desktop.native.profile_fresh', 'desktop.renderer.ready', @@ -105,8 +107,16 @@ const run = (file, args, { cwd, env, timeout = COMMAND_TIMEOUT_MS, input } = {}) }); let stdout = Buffer.alloc(0); let stderr = Buffer.alloc(0); - child.stdout.on('data', chunk => { stdout = appendBounded(stdout, chunk); }); - child.stderr.on('data', chunk => { stderr = appendBounded(stderr, chunk); }); + let stdoutOverflow = false; + let stderrOverflow = false; + child.stdout.on('data', chunk => { + stdoutOverflow ||= stdout.length + chunk.length > OUTPUT_CAP; + stdout = appendBounded(stdout, chunk); + }); + child.stderr.on('data', chunk => { + stderrOverflow ||= stderr.length + chunk.length > OUTPUT_CAP; + stderr = appendBounded(stderr, chunk); + }); if (input !== undefined) child.stdin.end(input); const timer = setTimeout(() => child.kill('SIGKILL'), timeout); child.once('error', error => { @@ -119,7 +129,7 @@ const run = (file, args, { cwd, env, timeout = COMMAND_TIMEOUT_MS, input } = {}) reject(new Error(`${basename(file)} failed with code ${code ?? 'null'} signal ${signal ?? 'none'}`)); return; } - resolveRun({ stdout, stderr }); + resolveRun({ stderr, stderrOverflow, stdout, stdoutOverflow }); }); }); @@ -127,14 +137,67 @@ const delay = milliseconds => new Promise(resolveDelay => setTimeout(resolveDela const errorFrom = (error, fallback) => error instanceof Error ? error : new Error(fallback); +export const NATIVE_LIFECYCLE_OPERATION_STAGES = Object.freeze([ + 'PREPARE_WORK_ROOT', + 'CREATE_PROFILE', + 'START_PROFILE_API', + 'BASELINE_BEFORE', + 'INSPECT_ARTIFACT', + 'EXTRACT_ARTIFACT', + 'VALIDATE_ARTIFACT', + 'PREPARE_LINUX_SANDBOX', + 'CREATE_CHILD_ENVIRONMENT', + 'FIRST_LAUNCH', + 'FIRST_INITIAL_EVIDENCE', + 'FIRST_SECURE_STORAGE_PROBE', + 'FIRST_RENDERER_READY', + 'WARM_MANUAL_DISPATCH', + 'WARM_MANUAL_EVIDENCE', + 'PROTOCOL_DISPATCH', + 'PROTOCOL_EVIDENCE', + 'WARM_OPEN_DISPATCH', + 'WARM_OPEN_EVIDENCE', + 'MALFORMED_DISPATCH', + 'MALFORMED_EVIDENCE', + 'OVERSIZED_DISPATCH', + 'OVERSIZED_EVIDENCE', + 'UNSAFE_SCHEME_DISPATCH', + 'UNSAFE_SCHEME_EVIDENCE', + 'FIRST_EXIT', + 'FIRST_EVIDENCE_VALIDATION', + 'RELAUNCH', + 'RELAUNCH_EXIT', + 'RELAUNCH_EVIDENCE', + 'FINAL_VALIDATION', +]); + +export class NativeLifecycleOperationFailure extends Error { + constructor(stage, operationError) { + if (!NATIVE_LIFECYCLE_OPERATION_STAGES.includes(stage)) { + throw new Error('Native lifecycle failure stage is invalid'); + } + super(`Native lifecycle operation failed [stage:${stage}]`); + this.name = 'NativeLifecycleOperationFailure'; + this.stage = stage; + Object.defineProperty(this, 'operationError', { value: operationError, enumerable: false }); + } +} + export class NativeLifecycleFailure extends AggregateError { constructor(primaryError, cleanupFailures) { const cleanupLabels = cleanupFailures.map(failure => failure.label).sort(); + const stage = primaryError instanceof NativeLifecycleOperationFailure + ? ` [stage:${primaryError.stage}]` + : ''; const message = primaryError - ? `Native lifecycle failed; cleanup also failed: ${cleanupLabels.join(', ')}` + ? `Native lifecycle failed${stage}; cleanup also failed: ${cleanupLabels.join(', ')}` : `Native lifecycle cleanup failed: ${cleanupLabels.join(', ')}`; const safeErrors = [ - ...(primaryError ? [new Error('Native lifecycle primary operation failed')] : []), + ...(primaryError ? [new Error( + primaryError instanceof NativeLifecycleOperationFailure + ? primaryError.message + : 'Native lifecycle primary operation failed', + )] : []), ...cleanupLabels.map(label => new Error(`Native lifecycle cleanup failed: ${label}`)), ]; super(safeErrors, message); @@ -151,24 +214,38 @@ const throwCombined = (primaryError, cleanupFailures) => { if (primaryError) throw primaryError; }; -const processGroupExists = pid => { - if (!pid) return false; - try { - process.kill(-pid, 0); - return true; - } catch (error) { - if (error?.code === 'ESRCH') return false; - throw error; +export const runningProcessGroupMembersFromPs = (output, processGroupId) => { + if (!Number.isSafeInteger(processGroupId) || processGroupId <= 0) { + throw new Error('Native process-group identity is invalid'); + } + const members = []; + for (const line of output.toString('utf8').split(/\r?\n/).filter(candidate => candidate.trim())) { + const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s*$/.exec(line); + if (!match) throw new Error('Native process-group inspection returned an invalid record'); + const pid = Number(match[1]); + const pgid = Number(match[2]); + const state = match[3][0]; + if (pgid === processGroupId && state !== 'Z') members.push(pid); + } + return members; +}; + +export const inspectRunningProcessGroupMembers = async processGroupId => { + if (!processGroupId) return []; + const result = await run('/bin/ps', ['-axo', 'pid=,pgid=,stat='], { timeout: CLEANUP_GRACE_MS }); + if (result.stdoutOverflow || result.stderrOverflow) { + throw new Error('Native process-group inspection exceeded its fixed output bound'); } + return runningProcessGroupMembersFromPs(result.stdout, processGroupId); }; const waitUntil = async (predicate, timeout) => { const deadline = Date.now() + timeout; while (Date.now() < deadline) { - if (!predicate()) return true; + if (!await predicate()) return true; await delay(25); } - return !predicate(); + return !await predicate(); }; class OwnedProcessGroup { @@ -212,13 +289,35 @@ class OwnedProcessGroup { await this.waitForClose(CLEANUP_GRACE_MS); return; } - if (processGroupExists(this.pid)) { + let initialMembers; + try { + initialMembers = await inspectRunningProcessGroupMembers(this.pid); + } catch { + // Inspection failure cannot relinquish authority: still bound TERM/KILL before reporting + // that the running-member postcondition could not be proved. + this.signal('SIGTERM'); + await delay(CLEANUP_GRACE_MS); + this.signal('SIGKILL'); + try { + await this.waitForClose(CLEANUP_GRACE_MS); + } catch { + throw new Error('Native application close and process-group inspection deadlines expired'); + } + throw new Error('Native application process-group inspection failed after bounded cleanup'); + } + if (initialMembers.length > 0) { this.signal('SIGTERM'); - if (!await waitUntil(() => processGroupExists(this.pid), CLEANUP_GRACE_MS)) { + if (!await waitUntil( + async () => (await inspectRunningProcessGroupMembers(this.pid)).length > 0, + CLEANUP_GRACE_MS, + )) { this.signal('SIGKILL'); } } - const groupGone = await waitUntil(() => processGroupExists(this.pid), CLEANUP_GRACE_MS); + const groupGone = await waitUntil( + async () => (await inspectRunningProcessGroupMembers(this.pid)).length > 0, + CLEANUP_GRACE_MS, + ); let closeError; try { await this.waitForClose(CLEANUP_GRACE_MS); @@ -230,7 +329,9 @@ class OwnedProcessGroup { } if (!groupGone) throw new Error('Native application process-group cleanup deadline expired'); if (closeError) throw closeError; - if (processGroupExists(this.pid)) throw new Error('Native application left a process in its owned process group'); + if ((await inspectRunningProcessGroupMembers(this.pid)).length > 0) { + throw new Error('Native application left a running process in its owned process group'); + } this.released = true; } @@ -250,8 +351,36 @@ class OwnedProcessGroup { } throwCombined(errorFrom(error, 'Native application close failed'), cleanupFailures); } - if (processGroupExists(this.pid)) { - const primaryError = new Error('Native application main process exited before its owned process group'); + let resultError = result?.error; + if (!resultError && result?.code !== 0) { + resultError = new Error( + `Native application exited with code ${result?.code ?? 'null'} signal ${result?.signal ?? 'none'}`, + ); + } + let naturallyDrained; + try { + naturallyDrained = await waitUntil( + async () => (await inspectRunningProcessGroupMembers(this.pid)).length > 0, + CLEANUP_GRACE_MS, + ); + } catch (error) { + const cleanupFailures = []; + try { + await this.terminate(); + } catch (cleanupError) { + cleanupFailures.push({ + label: 'process-groups', + error: errorFrom(cleanupError, 'Process-group cleanup failed'), + }); + } + throwCombined( + resultError ?? errorFrom(error, 'Native process-group drain inspection failed'), + cleanupFailures, + ); + } + if (!naturallyDrained) { + const primaryError = resultError + ?? new Error('Native application main process exited before its owned process group drained'); const cleanupFailures = []; try { await this.terminate(); @@ -266,10 +395,7 @@ class OwnedProcessGroup { // Relinquish authority only after proving that the complete group is gone; // this also prevents a later cleanup pass from acting on a reused PID. this.released = true; - if (result?.error) throw result.error; - if (result?.code !== 0) { - throw new Error(`Native application exited with code ${result?.code ?? 'null'} signal ${result?.signal ?? 'none'}`); - } + if (resultError) throw resultError; } } @@ -710,6 +836,35 @@ export class LaunchServicesAuthority { } } +export const removeCopiedApplicationWithLaunchServicesAuthority = async ({ + installRoot, + launchServices, +}, { + removeInstallRoot = path => rm(path, { recursive: true, force: true }), + assertInstallRootAbsent = path => assertAbsent( + path, + 'Native uninstall/remove left an owned install root behind', + ), +} = {}) => { + const failures = []; + const attempt = async (label, operation) => { + try { + await operation(); + } catch (error) { + failures.push({ label, error: errorFrom(error, 'Native lifecycle cleanup failed') }); + } + }; + if (launchServices?.registered) { + await attempt('launchservices-unregister', () => launchServices.unregister()); + await attempt('launchservices-postcondition', () => launchServices.assertGone()); + } + if (failures.length === 0) { + await attempt('install-root', () => removeInstallRoot(installRoot)); + await attempt('install-postcondition', () => assertInstallRootAbsent(installRoot)); + } + return failures; +}; + const macProtocolDispatch = async ({ launchServices, link, env }) => { await launchServices.register(); await run('/usr/bin/open', ['-b', APP_ID, link], { env, timeout: 15_000 }); @@ -899,6 +1054,27 @@ const assertDefaultUserDataUntouched = async target => { } }; +export const classifyFirstEvidenceDeadline = async path => { + try { + const events = new Set((await readFile(path, 'utf8')) + .trim() + .split('\n') + .filter(Boolean) + .map(line => JSON.parse(line)?.event)); + if (events.has('desktop.native.secure_storage_probe.started') + && !events.has('desktop.native.secure_storage_probe.completed')) { + return 'FIRST_SECURE_STORAGE_PROBE'; + } + if (events.has('desktop.native.secure_storage_probe.completed') + && !events.has('desktop.renderer.ready')) { + return 'FIRST_RENDERER_READY'; + } + } catch { + // The fixed initial-evidence stage remains actionable when no valid evidence exists. + } + return 'FIRST_INITIAL_EVIDENCE'; +}; + const lifecycleForArtifact = async ({ target, kind, artifact, report }) => { const workRoot = await mkdtemp(join(tmpdir(), `propr-native-${kind}-`)); const installRoot = join(workRoot, 'install'); @@ -911,29 +1087,40 @@ const lifecycleForArtifact = async ({ target, kind, artifact, report }) => { let launchServices; let sandboxPrepared = false; let primaryError; + let operationStage = 'PREPARE_WORK_ROOT'; try { await chmod(workRoot, 0o700); await mkdir(installRoot, { mode: 0o700 }); await mkdir(mountRoot, { mode: 0o700 }); const beforeDigest = await digest(artifact); + operationStage = 'CREATE_PROFILE'; profile = await createPrivateSmokeProfile(workRoot); + operationStage = 'START_PROFILE_API'; profileApi = await createProfileApi(); + operationStage = 'BASELINE_BEFORE'; await assertDefaultUserDataUntouched(target); + operationStage = 'INSPECT_ARTIFACT'; await inspectStagedArtifact({ artifact, kind, target, workRoot }); + operationStage = 'EXTRACT_ARTIFACT'; await extractArtifact({ artifact, kind, target, installRoot, mountAuthority }); + operationStage = 'VALIDATE_ARTIFACT'; application = await locateApplication({ ...target, kind, installRoot }); await assertSafeExtractedTree(installRoot); await validateIdentity({ target, kind, application }); if (target.platform === 'linux') { + operationStage = 'PREPARE_LINUX_SANDBOX'; const sandbox = join(application.applicationRoot, 'chrome-sandbox'); await run('/usr/bin/sudo', ['/usr/bin/chown', 'root:root', sandbox]); await run('/usr/bin/sudo', ['/usr/bin/chmod', '4755', sandbox]); sandboxPrepared = true; } + operationStage = 'CREATE_CHILD_ENVIRONMENT'; const baseEnvironment = await createSmokeChildEnvironment({ + platform: target.platform, profile, profileApiUrl: profileApi.url, + preserveMacosKeychainContext: target.platform === 'darwin', }); const firstEnvironment = Object.freeze({ ...baseEnvironment, @@ -946,26 +1133,44 @@ const lifecycleForArtifact = async ({ target, kind, artifact, report }) => { delete dispatchEnvironment.PROPR_DESKTOP_SMOKE_TEST; delete dispatchEnvironment.PROPR_DESKTOP_SMOKE_PROFILE_API_URL; + operationStage = 'FIRST_LAUNCH'; const first = startApplication(application, [ '--propr-smoke-test', `--user-data-dir=${profile.userData}`, COLD_MANUAL, ], firstEnvironment, workRoot, processGroups); const firstEvidence = join(profile.userData, 'application.smoke-evidence.first.jsonl'); - await waitForEvents(firstEvidence, ['desktop.renderer.ready', 'desktop.deeplink.cold_manual_once'], first.child); + operationStage = 'FIRST_INITIAL_EVIDENCE'; + try { + await waitForEvents(firstEvidence, ['desktop.renderer.ready', 'desktop.deeplink.cold_manual_once'], first.child); + } catch (error) { + if (error instanceof Error && error.message === 'Native application evidence deadline expired') { + operationStage = await classifyFirstEvidenceDeadline(firstEvidence); + } + throw error; + } + operationStage = 'WARM_MANUAL_DISPATCH'; await dispatchDirect(application, profile.userData, WARM_MANUAL, dispatchEnvironment, processGroups); + operationStage = 'WARM_MANUAL_EVIDENCE'; await waitForEvents(firstEvidence, ['desktop.deeplink.warm_manual_once'], first.child); if (target.platform === 'darwin') { launchServices = new LaunchServicesAuthority(application.applicationRoot, dispatchEnvironment); } + operationStage = 'PROTOCOL_DISPATCH'; const protocol = target.platform === 'linux' ? await linuxProtocolDispatch({ application, profile, link: WARM_TUNNEL, env: dispatchEnvironment, processGroups }) : await macProtocolDispatch({ launchServices, link: WARM_TUNNEL, env: dispatchEnvironment }); + operationStage = 'PROTOCOL_EVIDENCE'; await waitForEvents(firstEvidence, ['desktop.deeplink.warm_tunnel_once'], first.child); + operationStage = 'WARM_OPEN_DISPATCH'; await dispatchDirect(application, profile.userData, WARM_OPEN, dispatchEnvironment, processGroups); + operationStage = 'WARM_OPEN_EVIDENCE'; await waitForEvents(firstEvidence, ['desktop.deeplink.warm_open_once'], first.child); + operationStage = 'MALFORMED_DISPATCH'; await dispatchDirect(application, profile.userData, 'native-evidence-malformed', dispatchEnvironment, processGroups); + operationStage = 'MALFORMED_EVIDENCE'; await waitForEvents(firstEvidence, ['desktop.deeplink.rejected_malformed'], first.child); + operationStage = 'OVERSIZED_DISPATCH'; await dispatchDirect( application, profile.userData, @@ -973,7 +1178,9 @@ const lifecycleForArtifact = async ({ target, kind, artifact, report }) => { dispatchEnvironment, processGroups, ); + operationStage = 'OVERSIZED_EVIDENCE'; await waitForEvents(firstEvidence, ['desktop.deeplink.rejected_oversized'], first.child); + operationStage = 'UNSAFE_SCHEME_DISPATCH'; await dispatchDirect( application, profile.userData, @@ -981,13 +1188,16 @@ const lifecycleForArtifact = async ({ target, kind, artifact, report }) => { dispatchEnvironment, processGroups, ); + operationStage = 'UNSAFE_SCHEME_EVIDENCE'; await waitForEvents(firstEvidence, ['desktop.deeplink.rejected_unsafe_scheme'], first.child); + operationStage = 'FIRST_EXIT'; await first.waitForSuccessfulExit(); const requiredFirstEvents = target.platform === 'linux' - ? REQUIRED_FIRST_EVENTS.flatMap(event => event === 'desktop.native.secure_storage_enforced' + ? REQUIRED_FIRST_EVENTS.flatMap(event => event === 'desktop.native.secure_storage_probe.completed' ? ['desktop.native.secure_storage_fallback_refused', event] : [event]) : REQUIRED_FIRST_EVENTS; + operationStage = 'FIRST_EVIDENCE_VALIDATION'; await waitForEvents(firstEvidence, requiredFirstEvents, { exitCode: null }); await assertEvidenceOrdering(firstEvidence, requiredFirstEvents); await assertProfileAuthority(profile); @@ -999,13 +1209,16 @@ const lifecycleForArtifact = async ({ target, kind, artifact, report }) => { PROPR_DESKTOP_NATIVE_EXPECTED_PLATFORM: target.platform, PROPR_DESKTOP_NATIVE_EXPECTED_VERSION: target.version, }); + operationStage = 'RELAUNCH'; const relaunch = startApplication(application, [ '--propr-smoke-test', `--user-data-dir=${profile.userData}`, COLD_TUNNEL, ], relaunchEnvironment, workRoot, processGroups); + operationStage = 'RELAUNCH_EXIT'; await relaunch.waitForSuccessfulExit(); const relaunchEvidence = join(profile.userData, 'application.smoke-evidence.relaunch.jsonl'); + operationStage = 'RELAUNCH_EVIDENCE'; await waitForEvents( relaunchEvidence, REQUIRED_RELAUNCH_EVENTS, @@ -1013,6 +1226,7 @@ const lifecycleForArtifact = async ({ target, kind, artifact, report }) => { ); await assertEvidenceOrdering(relaunchEvidence, REQUIRED_RELAUNCH_EVENTS); await assertProfileAuthority(profile); + operationStage = 'FINAL_VALIDATION'; if (await digest(artifact) !== beforeDigest) throw new Error('Native lifecycle mutated the staged artifact bytes'); await assertDefaultUserDataUntouched(target); report.push({ @@ -1025,7 +1239,10 @@ const lifecycleForArtifact = async ({ target, kind, artifact, report }) => { : 'OS-protected Keychain round-trip and deletion', }); } catch (error) { - primaryError = errorFrom(error, 'Native lifecycle operation failed'); + primaryError = new NativeLifecycleOperationFailure( + operationStage, + errorFrom(error, 'Native lifecycle operation failed'), + ); } const cleanupFailures = await processGroups.cleanup(); @@ -1037,19 +1254,16 @@ const lifecycleForArtifact = async ({ target, kind, artifact, report }) => { } }; if (profileApi) await cleanup('profile-api', () => closeProfileApi(profileApi)); - if (launchServices?.registered) await cleanup('launchservices-unregister', () => launchServices.unregister()); if (mountAuthority?.mounted) await cleanup('dmg-mount', () => mountAuthority.detach()); if (sandboxPrepared && application) { await cleanup('linux-sandbox', () => run('/usr/bin/sudo', [ '/bin/rm', '-f', join(application.applicationRoot, 'chrome-sandbox'), ])); } - await cleanup('install-root', () => rm(installRoot, { recursive: true, force: true })); - await cleanup('install-postcondition', () => assertAbsent( + cleanupFailures.push(...await removeCopiedApplicationWithLaunchServicesAuthority({ installRoot, - 'Native uninstall/remove left an owned install root behind', - )); - if (launchServices?.registered) await cleanup('launchservices-postcondition', () => launchServices.assertGone()); + launchServices, + })); if (!mountAuthority?.mounted) { await cleanup('mount-root', () => rm(mountRoot, { recursive: true, force: true })); await cleanup('mount-postcondition', () => assertAbsent(mountRoot, 'Native DMG mount root remained after detach')); @@ -1061,6 +1275,10 @@ const lifecycleForArtifact = async ({ target, kind, artifact, report }) => { 'dmg-mount', 'mount-postcondition', 'profile-authority', + 'launchservices-unregister', + 'launchservices-postcondition', + 'install-root', + 'install-postcondition', ].includes(failure.label)); if (!blocksOuterRemoval) { await cleanup('work-root', () => rm(workRoot, { recursive: true, force: true })); diff --git a/apps/desktop/scripts/test-native-artifact-lifecycle.test.mjs b/apps/desktop/scripts/test-native-artifact-lifecycle.test.mjs index dc3d9fe31..405bbcaa9 100644 --- a/apps/desktop/scripts/test-native-artifact-lifecycle.test.mjs +++ b/apps/desktop/scripts/test-native-artifact-lifecycle.test.mjs @@ -8,15 +8,20 @@ import { inspect } from 'node:util'; import { assertArtifactSet, assertSafeExtractedTree, + classifyFirstEvidenceDeadline, closeProfileApi, DmgMountAuthority, extractDmg, extractRpm, + inspectRunningProcessGroupMembers, LaunchServicesAuthority, NativeLifecycleFailure, + NativeLifecycleOperationFailure, OwnedProcessGroups, parseArguments, + removeCopiedApplicationWithLaunchServicesAuthority, removeAuthorizedProfile, + runningProcessGroupMembersFromPs, waitForEvents, } from './test-native-artifact-lifecycle.mjs'; @@ -88,6 +93,58 @@ describe('native staged artifact lifecycle authority', () => { } }); + test('allows a successful parent a bounded natural same-group descendant drain', { + skip: process.platform === 'win32', + }, async () => { + const groups = new OwnedProcessGroups(); + const child = spawn(process.execPath, ['-e', ` + const { spawn } = require('node:child_process'); + spawn('/bin/sleep', ['0.15'], { stdio: 'ignore' }).unref(); + `], { detached: true, shell: false, stdio: 'ignore' }); + const group = groups.track(child); + try { + const started = Date.now(); + await group.waitForSuccessfulExit(3_000); + assert.ok(Date.now() - started >= 100, 'owned group was released before its descendant drained'); + assert.deepEqual(await inspectRunningProcessGroupMembers(child.pid), []); + } finally { + await groups.cleanup(); + } + }); + + test('kills and proves absence for a genuinely lingering successful-parent descendant', { + skip: process.platform === 'win32', + }, async () => { + const groups = new OwnedProcessGroups(); + const child = spawn(process.execPath, ['-e', ` + const { spawn } = require('node:child_process'); + spawn('/bin/sleep', ['30'], { stdio: 'ignore' }).unref(); + `], { detached: true, shell: false, stdio: 'ignore' }); + const group = groups.track(child); + try { + await assert.rejects(group.waitForSuccessfulExit(3_000), /owned process group drained/); + assert.deepEqual(await inspectRunningProcessGroupMembers(child.pid), []); + assert.deepEqual(await groups.cleanup(), []); + } finally { + await groups.cleanup(); + } + }); + + test('treats zombie-only process-group records as non-running without hiding live members', () => { + const records = Buffer.from([ + ' 410 410 Z', + ' 411 410 Z+', + ' 412 410 S', + ' 510 510 R+', + ].join('\n')); + assert.deepEqual(runningProcessGroupMembersFromPs(records, 410), [412]); + assert.deepEqual(runningProcessGroupMembersFromPs(Buffer.from(' 410 410 Z\n'), 410), []); + assert.throws( + () => runningProcessGroupMembersFromPs(Buffer.from('secret-capable malformed output\n'), 410), + /invalid record/, + ); + }); + for (const failurePoint of ['scan', 'copy']) { test(`detaches and verifies a DMG when ${failurePoint} fails after attach`, async () => { const calls = []; @@ -154,6 +211,31 @@ describe('native staged artifact lifecycle authority', () => { assert.equal(authority.mounted, true); }); + test('reports a fixed non-secret operation stage and classifies a stalled custody probe', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-native-stage-')); + const evidence = join(directory, 'evidence.jsonl'); + const privateFailure = new Error('failed at /private/profile with https://secret.invalid/token'); + try { + await writeFile(evidence, [ + JSON.stringify({ event: 'desktop.smoke.authorized' }), + JSON.stringify({ event: 'desktop.native.secure_storage_probe.started' }), + ].join('\n')); + assert.equal(await classifyFirstEvidenceDeadline(evidence), 'FIRST_SECURE_STORAGE_PROBE'); + + const operationFailure = new NativeLifecycleOperationFailure('FIRST_SECURE_STORAGE_PROBE', privateFailure); + const aggregate = new NativeLifecycleFailure(operationFailure, [{ + label: 'process-groups', + error: new Error('private cleanup output'), + }]); + assert.match(aggregate.message, /stage:FIRST_SECURE_STORAGE_PROBE/); + assert.doesNotMatch(String(aggregate), /private\/profile|secret\.invalid|private cleanup output/); + assert.doesNotMatch(JSON.stringify(aggregate), /private\/profile|secret\.invalid|private cleanup output/); + assert.doesNotMatch(inspect(aggregate), /private\/profile|secret\.invalid|private cleanup output/); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + test('surfaces LaunchServices unregister failure and stale exact registration', async () => { const applicationRoot = '/private/copied/ProPR Desktop.app'; const unregisterFailure = new LaunchServicesAuthority(applicationRoot, {}, { @@ -176,6 +258,53 @@ describe('native staged artifact lifecycle authority', () => { assert.equal(stale.registered, true); }); + test('retains the copied application until unregister and exact absence both succeed', async () => { + for (const failurePoint of ['unregister', 'postcondition']) { + const calls = []; + const launchServices = { + registered: true, + unregister: async () => { + calls.push('unregister'); + if (failurePoint === 'unregister') throw new Error('injected unregister failure'); + }, + assertGone: async () => { + calls.push('postcondition'); + if (failurePoint === 'postcondition') throw new Error('injected stale record'); + launchServices.registered = false; + }, + }; + const failures = await removeCopiedApplicationWithLaunchServicesAuthority({ + installRoot: '/private/install', + launchServices, + }, { + removeInstallRoot: async () => { calls.push('remove'); }, + assertInstallRootAbsent: async () => { calls.push('install-postcondition'); }, + }); + assert.deepEqual(calls, ['unregister', 'postcondition']); + assert.deepEqual(failures.map(failure => failure.label), [ + failurePoint === 'unregister' ? 'launchservices-unregister' : 'launchservices-postcondition', + ]); + } + + const calls = []; + const launchServices = { + registered: true, + unregister: async () => { calls.push('unregister'); }, + assertGone: async () => { + calls.push('postcondition'); + launchServices.registered = false; + }, + }; + assert.deepEqual(await removeCopiedApplicationWithLaunchServicesAuthority({ + installRoot: '/private/install', + launchServices, + }, { + removeInstallRoot: async () => { calls.push('remove'); }, + assertInstallRootAbsent: async () => { calls.push('install-postcondition'); }, + }), []); + assert.deepEqual(calls, ['unregister', 'postcondition', 'remove', 'install-postcondition']); + }); + test('does not mask profile API or private-profile authority cleanup failures', async () => { const server = { listening: true, diff --git a/apps/desktop/src/deep-link-failure-policy.test.ts b/apps/desktop/src/deep-link-failure-policy.test.ts new file mode 100644 index 000000000..19d2a256f --- /dev/null +++ b/apps/desktop/src/deep-link-failure-policy.test.ts @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { handleDeepLinkDeliveryFailure } from './deep-link-failure-policy'; + +describe('desktop deep-link failure policy', () => { + const exercise = (nativeArtifactSmoke: boolean) => { + const exits: number[] = []; + const logs: Array<{ event: string; fields: Readonly> }> = []; + handleDeepLinkDeliveryFailure(nativeArtifactSmoke, { + exit: code => { exits.push(code); }, + log: (_level, event, fields) => { logs.push({ event, fields }); }, + }); + return { exits, logs }; + }; + + it('is fatal when native artifact smoke loses renderer acknowledgement', () => { + const result = exercise(true); + assert.deepEqual(result.exits, [1]); + assert.deepEqual(result.logs, [ + { + event: 'desktop.deeplink.delivery_failed', + fields: { failure: 'renderer_acknowledgement' }, + }, + { + event: 'desktop.app.start_failed', + fields: { failure: 'renderer_acknowledgement' }, + }, + ]); + }); + + it('logs a fixed non-secret diagnostic without exiting normal production', () => { + const result = exercise(false); + assert.deepEqual(result.exits, []); + assert.deepEqual(result.logs, [{ + event: 'desktop.deeplink.delivery_failed', + fields: { failure: 'renderer_acknowledgement' }, + }]); + assert.equal(JSON.stringify(result), JSON.stringify(result).slice(0, 512)); + }); + + it('does not crash or exit production when the diagnostic sink fails', () => { + const exits: number[] = []; + assert.doesNotThrow(() => handleDeepLinkDeliveryFailure(false, { + exit: code => { exits.push(code); }, + log: () => { throw new Error('secret-bearing logger failure'); }, + })); + assert.deepEqual(exits, []); + }); +}); diff --git a/apps/desktop/src/deep-link-failure-policy.ts b/apps/desktop/src/deep-link-failure-policy.ts new file mode 100644 index 000000000..8615d80f2 --- /dev/null +++ b/apps/desktop/src/deep-link-failure-policy.ts @@ -0,0 +1,29 @@ +export interface DeepLinkFailurePolicyActions { + exit(code: number): void; + log( + level: 'error', + event: string, + fields: Readonly>, + ): void; +} + +/** Keeps renderer acknowledgement failures observable without exposing the link or renderer output. */ +export const handleDeepLinkDeliveryFailure = ( + nativeArtifactSmoke: boolean, + actions: DeepLinkFailurePolicyActions, +): void => { + const fields = { failure: 'renderer_acknowledgement' } as const; + try { + actions.log('error', 'desktop.deeplink.delivery_failed', fields); + } catch { + // A diagnostic sink must not turn an ordinary production delivery failure into a crash. + } + if (nativeArtifactSmoke) { + try { + actions.log('error', 'desktop.app.start_failed', fields); + } catch { + // Native evidence remains fail-closed even if its diagnostic sink is unavailable. + } + actions.exit(1); + } +}; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index c1f6aa187..18ee34a11 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -5,6 +5,7 @@ import { app, BrowserWindow, ipcMain, net, protocol, safeStorage, screen, sessio import type { Rectangle } from 'electron'; import { DESKTOP_RENDERER_ORIGIN } from '@propr/shared'; import { DeepLinkDelivery } from './deep-link-delivery'; +import { handleDeepLinkDeliveryFailure } from './deep-link-failure-policy'; import { registerIpcHandlers } from './ipc'; import { LocalLifecycleController } from './lifecycle'; import { createDesktopLogger, type DesktopLogger } from './logger'; @@ -126,7 +127,8 @@ const assertNativeRendererConsumption = ( if (consumption.kind !== expected.kind || consumption.target !== expected.target) { throw new Error('Native renderer deep-link acknowledgement did not prove the intended state'); } - if ([NATIVE_WARM_MANUAL_LINK, NATIVE_WARM_TUNNEL_LINK, NATIVE_WARM_OPEN_LINK].includes(value) + if (nativeSmokePhase + && [NATIVE_WARM_MANUAL_LINK, NATIVE_WARM_TUNNEL_LINK, NATIVE_WARM_OPEN_LINK].includes(value) && nativeSmokeWindow !== window) { throw new Error('Native warm deep link did not reach the already-running renderer'); } @@ -140,9 +142,11 @@ const deepLinkDelivery = new DeepLinkDelivery( if (event) recordNativeEvent(event); maybeCompleteNativeFirstLaunch(); }, - error => { - log('error', 'desktop.app.start_failed', { error }); - app.exit(1); + _error => { + handleDeepLinkDeliveryFailure(nativeSmokePhase !== undefined, { + exit: code => app.exit(code), + log, + }); }, ); let logger: DesktopLogger | null = null; @@ -472,6 +476,7 @@ const createMainWindow = async (): Promise => { label: 'Native ProPR Connect tunnel', apiBaseUrl: 'https://t-preserved.propr.dev', }); + recordNativeEvent('desktop.native.secure_storage_probe.started'); const storage = nativeProfiles?.security(); const credentialWrite = await nativeProfiles?.writeCredential('native-local', 'native-custody-probe'); if (!storage || !credentialWrite) throw new Error('Native secure-storage custody probe did not run'); @@ -497,6 +502,7 @@ const createMainWindow = async (): Promise => { if (process.platform === 'darwin' && (!storage.available || storage.backend !== 'os-protected')) { throw new Error('Native macOS artifact did not retain Keychain-backed custody'); } + recordNativeEvent('desktop.native.secure_storage_probe.completed'); recordNativeEvent('desktop.native.secure_storage_enforced'); recordNativeEvent('desktop.native.profile_fresh'); } else { diff --git a/apps/desktop/src/smoke-test-evidence.ts b/apps/desktop/src/smoke-test-evidence.ts index b4c278544..cb0262349 100644 --- a/apps/desktop/src/smoke-test-evidence.ts +++ b/apps/desktop/src/smoke-test-evidence.ts @@ -28,11 +28,14 @@ export const PACKAGED_SMOKE_EVIDENCE_EVENTS = [ ] as const; export const NATIVE_SMOKE_EVIDENCE_EVENTS = [ + 'desktop.deeplink.delivery_failed', 'desktop.native.identity_verified', 'desktop.native.profile_fresh', 'desktop.native.profile_preserved', 'desktop.native.secure_storage_enforced', 'desktop.native.secure_storage_fallback_refused', + 'desktop.native.secure_storage_probe.started', + 'desktop.native.secure_storage_probe.completed', 'desktop.deeplink.cold_manual_once', 'desktop.deeplink.cold_tunnel_once', 'desktop.deeplink.warm_manual_once', From d66aee2f42bf281ec770fe15c3f39f4594b07430 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:56 +0000 Subject: [PATCH 4/5] feat(ai): Implemented the two scoped corrections on exact head `90b86106e49d4c1e92ef00ddf36801a254ee37f3`. Implemented the two scoped corrections on exact head `90b86106e49d4c1e92ef00ddf36801a254ee37f3`. - Native Mac/Linux smoke now redirects Electron logs to a validated, pre-existing 0700 directory inside isolated user data before `getPath('logs')`. Production paths remain unchanged; log files are verified 0600. - Process-group cleanup/postcondition failures now preserve the copied executable/install root and outer work root, including Linux sandbox authority. - Added focused logging, ordering, and injected cleanup-orchestration tests. - Preserved macOS real-HOME Keychain handling and default-profile absence checks. - No Windows, lockfile, workflow, signing, release, or artifact changes. Validation: - Focused tests: 38 passed - Desktop typecheck: passed - `git diff --check`: passed - Changes left uncommitted as requested. PR: #2085 Comment by: @integry (ID: 5516723148) Model: gpt-5.6-sol --- .../test-native-artifact-lifecycle.mjs | 94 ++++++++++++++----- .../test-native-artifact-lifecycle.test.mjs | 24 +++++ apps/desktop/src/main.ts | 7 ++ apps/desktop/src/smoke-log-path.test.ts | 72 ++++++++++++++ apps/desktop/src/smoke-log-path.ts | 39 ++++++++ .../src/smoke-test-authorization.test.ts | 3 + 6 files changed, 218 insertions(+), 21 deletions(-) create mode 100644 apps/desktop/src/smoke-log-path.test.ts create mode 100644 apps/desktop/src/smoke-log-path.ts diff --git a/apps/desktop/scripts/test-native-artifact-lifecycle.mjs b/apps/desktop/scripts/test-native-artifact-lifecycle.mjs index 4e671062d..c9aa16249 100644 --- a/apps/desktop/scripts/test-native-artifact-lifecycle.mjs +++ b/apps/desktop/scripts/test-native-artifact-lifecycle.mjs @@ -865,6 +865,54 @@ export const removeCopiedApplicationWithLaunchServicesAuthority = async ({ return failures; }; +const processGroupAbsenceWasProved = cleanupFailures => ( + !cleanupFailures.some(failure => failure.label === 'process-groups') +); + +export const removeLifecycleRootsWithAuthority = async ({ + cleanupFailures, + installRoot, + launchServices, + workRoot, +}, { + removeCopiedApplication = removeCopiedApplicationWithLaunchServicesAuthority, + removeWorkRoot = path => rm(path, { recursive: true, force: true }), + assertWorkRootAbsent = path => assertAbsent( + path, + 'Native lifecycle work root remained after cleanup', + ), +} = {}) => { + const failures = [...cleanupFailures]; + const attempt = async (label, operation) => { + try { + await operation(); + } catch (error) { + failures.push({ label, error: errorFrom(error, 'Native lifecycle cleanup failed') }); + } + }; + + // A copied executable remains the only bounded remediation authority if the owned + // process group could still contain a live member. Do not unregister or remove it. + if (processGroupAbsenceWasProved(failures)) { + failures.push(...await removeCopiedApplication({ installRoot, launchServices })); + } + const blocksOuterRemoval = failures.some(failure => [ + 'process-groups', + 'dmg-mount', + 'mount-postcondition', + 'profile-authority', + 'launchservices-unregister', + 'launchservices-postcondition', + 'install-root', + 'install-postcondition', + ].includes(failure.label)); + if (!blocksOuterRemoval) { + await attempt('work-root', () => removeWorkRoot(workRoot)); + await attempt('work-postcondition', () => assertWorkRootAbsent(workRoot)); + } + return failures; +}; + const macProtocolDispatch = async ({ launchServices, link, env }) => { await launchServices.register(); await run('/usr/bin/open', ['-b', APP_ID, link], { env, timeout: 15_000 }); @@ -874,9 +922,21 @@ const macProtocolDispatch = async ({ launchServices, link, env }) => { const assertProfileAuthority = async profile => { const desktop = join(profile.userData, 'desktop'); const state = join(desktop, 'profiles.json'); - const [rootStats, desktopStats, stateStats] = await Promise.all([lstat(profile.root), lstat(desktop), lstat(state)]); + const logs = join(profile.userData, 'logs'); + const logsFromRoot = relative(profile.root, logs); + const log = join(logs, 'desktop.jsonl'); + const [rootStats, desktopStats, stateStats, logsStats, logStats] = await Promise.all([ + lstat(profile.root), + lstat(desktop), + lstat(state), + lstat(logs), + lstat(log), + ]); if ((rootStats.mode & 0o777) !== 0o700 || (desktopStats.mode & 0o777) !== 0o700 - || (stateStats.mode & 0o777) !== 0o600 || stateStats.isSymbolicLink()) { + || (stateStats.mode & 0o777) !== 0o600 || stateStats.isSymbolicLink() + || !logsFromRoot || logsFromRoot.startsWith('..') || isAbsolute(logsFromRoot) + || (logsStats.mode & 0o777) !== 0o700 || logsStats.isSymbolicLink() + || (logStats.mode & 0o777) !== 0o600 || logStats.isSymbolicLink()) { throw new Error('Native profile state did not retain 0700/0600 authority'); } const contents = await readFile(state, 'utf8'); @@ -1095,6 +1155,9 @@ const lifecycleForArtifact = async ({ target, kind, artifact, report }) => { const beforeDigest = await digest(artifact); operationStage = 'CREATE_PROFILE'; profile = await createPrivateSmokeProfile(workRoot); + const logsDirectory = join(profile.userData, 'logs'); + await mkdir(logsDirectory, { mode: 0o700 }); + await chmod(logsDirectory, 0o700); operationStage = 'START_PROFILE_API'; profileApi = await createProfileApi(); operationStage = 'BASELINE_BEFORE'; @@ -1255,15 +1318,11 @@ const lifecycleForArtifact = async ({ target, kind, artifact, report }) => { }; if (profileApi) await cleanup('profile-api', () => closeProfileApi(profileApi)); if (mountAuthority?.mounted) await cleanup('dmg-mount', () => mountAuthority.detach()); - if (sandboxPrepared && application) { + if (processGroupAbsenceWasProved(cleanupFailures) && sandboxPrepared && application) { await cleanup('linux-sandbox', () => run('/usr/bin/sudo', [ '/bin/rm', '-f', join(application.applicationRoot, 'chrome-sandbox'), ])); } - cleanupFailures.push(...await removeCopiedApplicationWithLaunchServicesAuthority({ - installRoot, - launchServices, - })); if (!mountAuthority?.mounted) { await cleanup('mount-root', () => rm(mountRoot, { recursive: true, force: true })); await cleanup('mount-postcondition', () => assertAbsent(mountRoot, 'Native DMG mount root remained after detach')); @@ -1271,20 +1330,13 @@ const lifecycleForArtifact = async ({ target, kind, artifact, report }) => { if (profile) { await cleanup('profile-authority', () => removeAuthorizedProfile(profile)); } - const blocksOuterRemoval = cleanupFailures.some(failure => [ - 'dmg-mount', - 'mount-postcondition', - 'profile-authority', - 'launchservices-unregister', - 'launchservices-postcondition', - 'install-root', - 'install-postcondition', - ].includes(failure.label)); - if (!blocksOuterRemoval) { - await cleanup('work-root', () => rm(workRoot, { recursive: true, force: true })); - await cleanup('work-postcondition', () => assertAbsent(workRoot, 'Native lifecycle work root remained after cleanup')); - } - throwCombined(primaryError, cleanupFailures); + const finalCleanupFailures = await removeLifecycleRootsWithAuthority({ + cleanupFailures, + installRoot, + launchServices, + workRoot, + }); + throwCombined(primaryError, finalCleanupFailures); }; export const runNativeArtifactLifecycle = async target => { diff --git a/apps/desktop/scripts/test-native-artifact-lifecycle.test.mjs b/apps/desktop/scripts/test-native-artifact-lifecycle.test.mjs index 405bbcaa9..145ad8297 100644 --- a/apps/desktop/scripts/test-native-artifact-lifecycle.test.mjs +++ b/apps/desktop/scripts/test-native-artifact-lifecycle.test.mjs @@ -20,6 +20,7 @@ import { OwnedProcessGroups, parseArguments, removeCopiedApplicationWithLaunchServicesAuthority, + removeLifecycleRootsWithAuthority, removeAuthorizedProfile, runningProcessGroupMembersFromPs, waitForEvents, @@ -305,6 +306,29 @@ describe('native staged artifact lifecycle authority', () => { assert.deepEqual(calls, ['unregister', 'postcondition', 'remove', 'install-postcondition']); }); + test('retains copied install and outer work roots when process-group absence cannot be proved', async () => { + const calls = []; + const processGroupFailure = { + label: 'process-groups', + error: new Error('injected process-group postcondition failure'), + }; + const failures = await removeLifecycleRootsWithAuthority({ + cleanupFailures: [processGroupFailure], + installRoot: '/private/work/install', + launchServices: { registered: true }, + workRoot: '/private/work', + }, { + removeCopiedApplication: async () => { + calls.push('remove-copied-application'); + return []; + }, + removeWorkRoot: async () => { calls.push('remove-work-root'); }, + assertWorkRootAbsent: async () => { calls.push('work-postcondition'); }, + }); + assert.deepEqual(calls, []); + assert.deepEqual(failures, [processGroupFailure]); + }); + test('does not mask profile API or private-profile authority cleanup failures', async () => { const server = { listening: true, diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 18ee34a11..af73f7384 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -24,6 +24,7 @@ import type { DesktopDeepLinkConsumption } from './shared/contract'; import { checkForSignedUpdates } from './signed-updates'; import { authorizePackagedSmokeTest } from './smoke-test-authorization'; import { createPackagedSmokeEvidenceSink } from './smoke-test-evidence'; +import { configureNativeSmokeLogsPath } from './smoke-log-path'; import { createBrowserWindowOptions, MINIMUM_BROWSER_WINDOW_SIZE, @@ -69,6 +70,12 @@ try { throw new Error('Packaged desktop smoke --user-data-dir must be an existing non-link directory'); } app.setPath('userData', packagedSmokeUserDataDirectory); + configureNativeSmokeLogsPath({ + app, + authorizedNativeSmoke: nativeSmokePhase !== undefined, + platform: process.platform, + userDataDirectory: packagedSmokeUserDataDirectory, + }); packagedSmokeEvidence = nativeSmokePhase ? createPackagedSmokeEvidenceSink(packagedSmokeUserDataDirectory, nativeSmokePhase) : createPackagedSmokeEvidenceSink(packagedSmokeUserDataDirectory); diff --git a/apps/desktop/src/smoke-log-path.test.ts b/apps/desktop/src/smoke-log-path.test.ts new file mode 100644 index 000000000..5006e0eb8 --- /dev/null +++ b/apps/desktop/src/smoke-log-path.test.ts @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { isAbsolute, join, relative } from 'node:path'; +import { describe, it } from 'node:test'; +import { createDesktopLogger } from './logger'; +import { configureNativeSmokeLogsPath } from './smoke-log-path'; + +const waitForFile = async (path: string): Promise => { + const deadline = Date.now() + 2_000; + while (Date.now() < deadline) { + try { + readFileSync(path); + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + await new Promise(resolve => setTimeout(resolve, 10)); + } + } + throw new Error('Timed out waiting for the smoke log file'); +}; + +describe('native smoke Electron logs path', () => { + it('leaves ordinary production and Windows paths unchanged', () => { + const calls: Array<[string, string]> = []; + const app = { setPath: (name: 'logs', path: string) => calls.push([name, path]) }; + assert.equal(configureNativeSmokeLogsPath({ + app, + authorizedNativeSmoke: false, + platform: 'darwin', + userDataDirectory: '/private/unused', + }), null); + assert.equal(configureNativeSmokeLogsPath({ + app, + authorizedNativeSmoke: true, + platform: 'win32', + userDataDirectory: 'C:\\unused', + }), null); + assert.deepEqual(calls, []); + }); + + it('keeps authorized Mac/Linux smoke logs at 0700/0600 inside the isolated profile', async () => { + for (const platform of ['darwin', 'linux'] as const) { + const profile = mkdtempSync(join(tmpdir(), 'propr-desktop-smoke-log-')); + const logs = join(profile, 'logs'); + const calls: Array<[string, string]> = []; + try { + mkdirSync(logs, { mode: 0o700 }); + chmodSync(logs, 0o700); + const configured = configureNativeSmokeLogsPath({ + app: { setPath: (name, path) => calls.push([name, path]) }, + authorizedNativeSmoke: true, + platform, + userDataDirectory: profile, + }); + assert.equal(configured, logs); + assert.deepEqual(calls, [['logs', logs]]); + assert.ok(configured); + + const log = join(configured, 'desktop.jsonl'); + createDesktopLogger(log).log('info', 'desktop.test'); + await waitForFile(log); + const logsFromProfile = relative(profile, configured); + assert.ok(logsFromProfile && !logsFromProfile.startsWith('..') && !isAbsolute(logsFromProfile)); + assert.equal(lstatSync(configured).mode & 0o777, 0o700); + assert.equal(lstatSync(log).mode & 0o777, 0o600); + } finally { + rmSync(profile, { recursive: true, force: true }); + } + } + }); +}); diff --git a/apps/desktop/src/smoke-log-path.ts b/apps/desktop/src/smoke-log-path.ts new file mode 100644 index 000000000..798cdb675 --- /dev/null +++ b/apps/desktop/src/smoke-log-path.ts @@ -0,0 +1,39 @@ +import { lstatSync, type Stats } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; + +type ElectronLogsPath = { + setPath(name: 'logs', path: string): void; +}; + +export const configureNativeSmokeLogsPath = ({ + app, + authorizedNativeSmoke, + platform, + userDataDirectory, + inspectDirectory = lstatSync, + currentUserId = typeof process.getuid === 'function' ? process.getuid() : undefined, +}: { + app: ElectronLogsPath; + authorizedNativeSmoke: boolean; + platform: NodeJS.Platform; + userDataDirectory: string; + inspectDirectory?: (path: string) => Stats; + currentUserId?: number; +}): string | null => { + if (!authorizedNativeSmoke || platform === 'win32') return null; + + const resolvedUserData = resolve(userDataDirectory); + const logsDirectory = join(resolvedUserData, 'logs'); + const logsFromUserData = relative(resolvedUserData, logsDirectory); + if (!logsFromUserData || logsFromUserData.startsWith('..')) { + throw new Error('Native smoke logs directory escaped the isolated user-data root'); + } + + const stats = inspectDirectory(logsDirectory); + if (!stats.isDirectory() || stats.isSymbolicLink() || (stats.mode & 0o777) !== 0o700 + || currentUserId === undefined || stats.uid !== currentUserId) { + throw new Error('Native smoke logs directory does not have owned 0700 authority'); + } + app.setPath('logs', logsDirectory); + return logsDirectory; +}; diff --git a/apps/desktop/src/smoke-test-authorization.test.ts b/apps/desktop/src/smoke-test-authorization.test.ts index 39058a3c1..bd730f8b3 100644 --- a/apps/desktop/src/smoke-test-authorization.test.ts +++ b/apps/desktop/src/smoke-test-authorization.test.ts @@ -101,8 +101,11 @@ describe('packaged smoke profile authorization', () => { const main = readFileSync(fileURLToPath(new URL('./main.ts', import.meta.url)), 'utf8'); const authorization = main.indexOf('authorizePackagedSmokeTest({'); const isolation = main.indexOf("app.setPath('userData', packagedSmokeUserDataDirectory)"); + const logsIsolation = main.indexOf('configureNativeSmokeLogsPath({', isolation); + const logsRead = main.indexOf("app.getPath('logs')"); assert.notEqual(authorization, -1); assert.ok(authorization < isolation); + assert.ok(isolation < logsIsolation && logsIsolation < logsRead); assert.ok(isolation < main.indexOf('new ProfileStore(')); assert.ok(isolation < main.indexOf('new LocalLifecycleController(')); assert.ok(authorization < main.indexOf('new ProfileStore(')); From 9ba00e5f239be59e4148ded9a2348bea124f67d4 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:53:25 +0000 Subject: [PATCH 5/5] feat(ai): Implemented the final native-gate correction on head `d66aee2f`. Implemented the final native-gate correction on head `d66aee2f`. - Evidence is validated before child exit, with a stable re-read closing the final-event race. - Added complete/incomplete clean-exit fixtures and fixed milestone/result classifications. - macOS now reports `LS_REGISTER` versus `OPEN_DISPATCH` and uses `open -a `. - Native smoke ACK timeout is bounded at 15 seconds; production remains 5 seconds. - LaunchServices unregister, ordering, and exactly-once proofs remain intact. - No Windows, workflow, lockfile, signing, or release-profile changes. Validation passed: - Native lifecycle tests: 19/19 - Release workflow contract tests: 24/24 - Full desktop suite: 207 passed, 6 skipped - Desktop typecheck - `git diff --check` The unchanged required matrix will provide fresh Linux x64/arm64 and macOS x64/arm64 native gates; those should pass before merge. PR: #2085 Comment by: @integry (ID: 5516860721) Model: gpt-5.6-sol --- .../test-native-artifact-lifecycle.mjs | 183 +++++++++++++----- .../test-native-artifact-lifecycle.test.mjs | 97 +++++++++- apps/desktop/src/deep-link-delivery.test.ts | 11 +- apps/desktop/src/deep-link-delivery.ts | 11 +- apps/desktop/src/main.ts | 5 +- apps/desktop/src/release-workflow.test.ts | 2 +- 6 files changed, 251 insertions(+), 58 deletions(-) diff --git a/apps/desktop/scripts/test-native-artifact-lifecycle.mjs b/apps/desktop/scripts/test-native-artifact-lifecycle.mjs index c9aa16249..7d6c56277 100644 --- a/apps/desktop/scripts/test-native-artifact-lifecycle.mjs +++ b/apps/desktop/scripts/test-native-artifact-lifecycle.mjs @@ -154,6 +154,8 @@ export const NATIVE_LIFECYCLE_OPERATION_STAGES = Object.freeze([ 'WARM_MANUAL_DISPATCH', 'WARM_MANUAL_EVIDENCE', 'PROTOCOL_DISPATCH', + 'LS_REGISTER', + 'OPEN_DISPATCH', 'PROTOCOL_EVIDENCE', 'WARM_OPEN_DISPATCH', 'WARM_OPEN_EVIDENCE', @@ -171,14 +173,57 @@ export const NATIVE_LIFECYCLE_OPERATION_STAGES = Object.freeze([ 'FINAL_VALIDATION', ]); +export const NATIVE_LIFECYCLE_EVIDENCE_RESULT_CLASSES = Object.freeze([ + 'CLEAN_EXIT', + 'FAILED_EXIT', + 'SIGNALLED', + 'EVIDENCE_DEADLINE', +]); + +export const FIRST_EVIDENCE_MILESTONES = Object.freeze([ + 'NO_EVIDENCE', + 'AUTHORIZED', + 'IDENTITY', + 'DEEP_LINK_DELIVERY_FAILURE', + 'COLD_ACK', + 'SECURE_STORAGE_STARTED', + 'SECURE_STORAGE_COMPLETED', + 'RENDERER', +]); + +export class NativeLifecycleEvidenceWaitFailure extends Error { + constructor(resultClass) { + if (!NATIVE_LIFECYCLE_EVIDENCE_RESULT_CLASSES.includes(resultClass)) { + throw new Error('Native lifecycle evidence result class is invalid'); + } + super(resultClass === 'EVIDENCE_DEADLINE' + ? 'Native application evidence deadline expired' + : 'Native application exited before producing required evidence'); + this.name = 'NativeLifecycleEvidenceWaitFailure'; + this.resultClass = resultClass; + } +} + export class NativeLifecycleOperationFailure extends Error { - constructor(stage, operationError) { + constructor(stage, operationError, evidenceClassification) { if (!NATIVE_LIFECYCLE_OPERATION_STAGES.includes(stage)) { throw new Error('Native lifecycle failure stage is invalid'); } - super(`Native lifecycle operation failed [stage:${stage}]`); + if (evidenceClassification + && (!FIRST_EVIDENCE_MILESTONES.includes(evidenceClassification.milestone) + || !NATIVE_LIFECYCLE_EVIDENCE_RESULT_CLASSES.includes(evidenceClassification.resultClass))) { + throw new Error('Native lifecycle evidence failure classification is invalid'); + } + const classification = evidenceClassification + ? ` [milestone:${evidenceClassification.milestone}] [result:${evidenceClassification.resultClass}]` + : ''; + super(`Native lifecycle operation failed [stage:${stage}]${classification}`); this.name = 'NativeLifecycleOperationFailure'; this.stage = stage; + if (evidenceClassification) { + this.milestone = evidenceClassification.milestone; + this.resultClass = evidenceClassification.resultClass; + } Object.defineProperty(this, 'operationError', { value: operationError, enumerable: false }); } } @@ -186,11 +231,15 @@ export class NativeLifecycleOperationFailure extends Error { export class NativeLifecycleFailure extends AggregateError { constructor(primaryError, cleanupFailures) { const cleanupLabels = cleanupFailures.map(failure => failure.label).sort(); - const stage = primaryError instanceof NativeLifecycleOperationFailure - ? ` [stage:${primaryError.stage}]` + const classification = primaryError instanceof NativeLifecycleOperationFailure + ? [ + ` [stage:${primaryError.stage}]`, + ...(primaryError.milestone ? [` [milestone:${primaryError.milestone}]`] : []), + ...(primaryError.resultClass ? [` [result:${primaryError.resultClass}]`] : []), + ].join('') : ''; const message = primaryError - ? `Native lifecycle failed${stage}; cleanup also failed: ${cleanupLabels.join(', ')}` + ? `Native lifecycle failed${classification}; cleanup also failed: ${cleanupLabels.join(', ')}` : `Native lifecycle cleanup failed: ${cleanupLabels.join(', ')}`; const safeErrors = [ ...(primaryError ? [new Error( @@ -723,25 +772,47 @@ const validateIdentity = async ({ target, kind, application }) => { } }; +const readFixedEvidenceEvents = async path => { + const records = (await readFile(path, 'utf8')).trim().split('\n').filter(Boolean).map(line => JSON.parse(line)); + if (records.some(record => Object.keys(record).length !== 1 || typeof record.event !== 'string')) { + throw new Error('Native application emitted secret-capable evidence fields'); + } + return records.map(record => record.event); +}; + export const waitForEvents = async (path, events, child, timeout = PROCESS_TIMEOUT_MS) => { const deadline = Date.now() + timeout; - while (Date.now() < deadline) { - const exited = child.exitCode !== null - || (child.signalCode !== undefined && child.signalCode !== null); - if (exited) throw new Error('Native application exited before producing required evidence'); + const hasRequiredEvents = async () => { + const names = await readFixedEvidenceEvents(path); + return events.every(event => names.includes(event)); + }; + while (true) { try { - const records = (await readFile(path, 'utf8')).trim().split('\n').filter(Boolean).map(line => JSON.parse(line)); - const names = records.map(record => record.event); - if (records.some(record => Object.keys(record).length !== 1 || typeof record.event !== 'string')) { - throw new Error('Native application emitted secret-capable evidence fields'); - } - if (events.every(event => names.includes(event))) return; + if (await hasRequiredEvents()) return; } catch (error) { if (error?.code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error; } - await new Promise(resolveWait => setTimeout(resolveWait, 50)); + const signalled = child.signalCode !== undefined && child.signalCode !== null; + const exited = child.exitCode !== null || signalled; + if (exited) { + // Exit can become observable after the first read even though the child's + // final fsynced event preceded that exit. Re-read the now-stable file once. + try { + if (await hasRequiredEvents()) return; + } catch (error) { + if (error?.code !== 'ENOENT') { + if (error instanceof SyntaxError) throw new Error('Native application evidence was malformed'); + throw error; + } + } + const resultClass = signalled ? 'SIGNALLED' : child.exitCode === 0 ? 'CLEAN_EXIT' : 'FAILED_EXIT'; + throw new NativeLifecycleEvidenceWaitFailure(resultClass); + } + const remaining = deadline - Date.now(); + if (remaining <= 0) break; + await delay(Math.min(50, remaining)); } - throw new Error('Native application evidence deadline expired'); + throw new NativeLifecycleEvidenceWaitFailure('EVIDENCE_DEADLINE'); }; const assertEvidenceOrdering = async (path, requiredEvents) => { @@ -814,6 +885,14 @@ export class LaunchServicesAuthority { this.registered = true; } + async dispatch(link) { + if (!this.registered) throw new Error('Copied application must be registered before LaunchServices dispatch'); + await this.runCommand('/usr/bin/open', ['-a', this.applicationRoot, link], { + env: this.environment, + timeout: 15_000, + }); + } + async unregister() { if (!this.registered) return; await this.runCommand(LAUNCH_SERVICES, ['-u', this.applicationRoot], { env: this.environment, timeout: 30_000 }); @@ -913,12 +992,6 @@ export const removeLifecycleRootsWithAuthority = async ({ return failures; }; -const macProtocolDispatch = async ({ launchServices, link, env }) => { - await launchServices.register(); - await run('/usr/bin/open', ['-b', APP_ID, link], { env, timeout: 15_000 }); - return 'LaunchServices-registration+open-bundle-dispatch'; -}; - const assertProfileAuthority = async profile => { const desktop = join(profile.userData, 'desktop'); const state = join(desktop, 'profiles.json'); @@ -1114,25 +1187,29 @@ const assertDefaultUserDataUntouched = async target => { } }; -export const classifyFirstEvidenceDeadline = async path => { +export const classifyFirstEvidenceFailure = async (path, resultClass) => { + if (!NATIVE_LIFECYCLE_EVIDENCE_RESULT_CLASSES.includes(resultClass)) { + throw new Error('Native lifecycle evidence result class is invalid'); + } + let milestone = 'NO_EVIDENCE'; try { - const events = new Set((await readFile(path, 'utf8')) - .trim() - .split('\n') - .filter(Boolean) - .map(line => JSON.parse(line)?.event)); - if (events.has('desktop.native.secure_storage_probe.started') - && !events.has('desktop.native.secure_storage_probe.completed')) { - return 'FIRST_SECURE_STORAGE_PROBE'; - } - if (events.has('desktop.native.secure_storage_probe.completed') - && !events.has('desktop.renderer.ready')) { - return 'FIRST_RENDERER_READY'; - } + const events = new Set(await readFixedEvidenceEvents(path)); + if (events.has('desktop.smoke.authorized')) milestone = 'AUTHORIZED'; + if (events.has('desktop.native.identity_verified')) milestone = 'IDENTITY'; + if (events.has('desktop.deeplink.delivery_failed')) milestone = 'DEEP_LINK_DELIVERY_FAILURE'; + if (events.has('desktop.deeplink.cold_manual_once')) milestone = 'COLD_ACK'; + if (events.has('desktop.native.secure_storage_probe.started')) milestone = 'SECURE_STORAGE_STARTED'; + if (events.has('desktop.native.secure_storage_probe.completed')) milestone = 'SECURE_STORAGE_COMPLETED'; + if (events.has('desktop.renderer.ready')) milestone = 'RENDERER'; } catch { - // The fixed initial-evidence stage remains actionable when no valid evidence exists. + // Only fixed classifications may cross the native-gate diagnostic boundary. } - return 'FIRST_INITIAL_EVIDENCE'; + const stage = milestone === 'SECURE_STORAGE_STARTED' + ? 'FIRST_SECURE_STORAGE_PROBE' + : ['SECURE_STORAGE_COMPLETED', 'RENDERER'].includes(milestone) + ? 'FIRST_RENDERER_READY' + : 'FIRST_INITIAL_EVIDENCE'; + return { milestone, resultClass, stage }; }; const lifecycleForArtifact = async ({ target, kind, artifact, report }) => { @@ -1147,6 +1224,7 @@ const lifecycleForArtifact = async ({ target, kind, artifact, report }) => { let launchServices; let sandboxPrepared = false; let primaryError; + let evidenceClassification; let operationStage = 'PREPARE_WORK_ROOT'; try { await chmod(workRoot, 0o700); @@ -1207,8 +1285,9 @@ const lifecycleForArtifact = async ({ target, kind, artifact, report }) => { try { await waitForEvents(firstEvidence, ['desktop.renderer.ready', 'desktop.deeplink.cold_manual_once'], first.child); } catch (error) { - if (error instanceof Error && error.message === 'Native application evidence deadline expired') { - operationStage = await classifyFirstEvidenceDeadline(firstEvidence); + if (error instanceof NativeLifecycleEvidenceWaitFailure) { + evidenceClassification = await classifyFirstEvidenceFailure(firstEvidence, error.resultClass); + operationStage = evidenceClassification.stage; } throw error; } @@ -1219,10 +1298,23 @@ const lifecycleForArtifact = async ({ target, kind, artifact, report }) => { if (target.platform === 'darwin') { launchServices = new LaunchServicesAuthority(application.applicationRoot, dispatchEnvironment); } - operationStage = 'PROTOCOL_DISPATCH'; - const protocol = target.platform === 'linux' - ? await linuxProtocolDispatch({ application, profile, link: WARM_TUNNEL, env: dispatchEnvironment, processGroups }) - : await macProtocolDispatch({ launchServices, link: WARM_TUNNEL, env: dispatchEnvironment }); + let protocol; + if (target.platform === 'linux') { + operationStage = 'PROTOCOL_DISPATCH'; + protocol = await linuxProtocolDispatch({ + application, + profile, + link: WARM_TUNNEL, + env: dispatchEnvironment, + processGroups, + }); + } else { + operationStage = 'LS_REGISTER'; + await launchServices.register(); + operationStage = 'OPEN_DISPATCH'; + await launchServices.dispatch(WARM_TUNNEL); + protocol = 'LaunchServices-registration+open-exact-application-dispatch'; + } operationStage = 'PROTOCOL_EVIDENCE'; await waitForEvents(firstEvidence, ['desktop.deeplink.warm_tunnel_once'], first.child); operationStage = 'WARM_OPEN_DISPATCH'; @@ -1305,6 +1397,7 @@ const lifecycleForArtifact = async ({ target, kind, artifact, report }) => { primaryError = new NativeLifecycleOperationFailure( operationStage, errorFrom(error, 'Native lifecycle operation failed'), + evidenceClassification, ); } diff --git a/apps/desktop/scripts/test-native-artifact-lifecycle.test.mjs b/apps/desktop/scripts/test-native-artifact-lifecycle.test.mjs index 145ad8297..4d45c5149 100644 --- a/apps/desktop/scripts/test-native-artifact-lifecycle.test.mjs +++ b/apps/desktop/scripts/test-native-artifact-lifecycle.test.mjs @@ -8,13 +8,14 @@ import { inspect } from 'node:util'; import { assertArtifactSet, assertSafeExtractedTree, - classifyFirstEvidenceDeadline, + classifyFirstEvidenceFailure, closeProfileApi, DmgMountAuthority, extractDmg, extractRpm, inspectRunningProcessGroupMembers, LaunchServicesAuthority, + NativeLifecycleEvidenceWaitFailure, NativeLifecycleFailure, NativeLifecycleOperationFailure, OwnedProcessGroups, @@ -94,6 +95,28 @@ describe('native staged artifact lifecycle authority', () => { } }); + test('reads fixed evidence before classifying a clean child exit', async () => { + const directory = await mkdtemp(join(tmpdir(), 'propr-native-exited-evidence-')); + const complete = join(directory, 'complete.jsonl'); + const incomplete = join(directory, 'incomplete.jsonl'); + const exitedChild = { exitCode: 0, signalCode: null }; + try { + await writeFile(complete, [ + JSON.stringify({ event: 'first' }), + JSON.stringify({ event: 'second' }), + ].join('\n')); + await writeFile(incomplete, `${JSON.stringify({ event: 'first' })}\n`); + + await assert.doesNotReject(waitForEvents(complete, ['first', 'second'], exitedChild, 10)); + await assert.rejects(waitForEvents(incomplete, ['first', 'second'], exitedChild, 10), error => ( + error instanceof NativeLifecycleEvidenceWaitFailure + && error.resultClass === 'CLEAN_EXIT' + )); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + test('allows a successful parent a bounded natural same-group descendant drain', { skip: process.platform === 'win32', }, async () => { @@ -212,23 +235,55 @@ describe('native staged artifact lifecycle authority', () => { assert.equal(authority.mounted, true); }); - test('reports a fixed non-secret operation stage and classifies a stalled custody probe', async () => { + test('classifies first-evidence exits by fixed non-secret milestone and result class', async () => { const directory = await mkdtemp(join(tmpdir(), 'propr-native-stage-')); const evidence = join(directory, 'evidence.jsonl'); const privateFailure = new Error('failed at /private/profile with https://secret.invalid/token'); try { - await writeFile(evidence, [ - JSON.stringify({ event: 'desktop.smoke.authorized' }), - JSON.stringify({ event: 'desktop.native.secure_storage_probe.started' }), - ].join('\n')); - assert.equal(await classifyFirstEvidenceDeadline(evidence), 'FIRST_SECURE_STORAGE_PROBE'); + const cases = [ + { event: null, milestone: 'NO_EVIDENCE', stage: 'FIRST_INITIAL_EVIDENCE' }, + { event: 'desktop.smoke.authorized', milestone: 'AUTHORIZED', stage: 'FIRST_INITIAL_EVIDENCE' }, + { event: 'desktop.native.identity_verified', milestone: 'IDENTITY', stage: 'FIRST_INITIAL_EVIDENCE' }, + { + event: 'desktop.deeplink.delivery_failed', + milestone: 'DEEP_LINK_DELIVERY_FAILURE', + stage: 'FIRST_INITIAL_EVIDENCE', + }, + { event: 'desktop.deeplink.cold_manual_once', milestone: 'COLD_ACK', stage: 'FIRST_INITIAL_EVIDENCE' }, + { + event: 'desktop.native.secure_storage_probe.started', + milestone: 'SECURE_STORAGE_STARTED', + stage: 'FIRST_SECURE_STORAGE_PROBE', + }, + { + event: 'desktop.native.secure_storage_probe.completed', + milestone: 'SECURE_STORAGE_COMPLETED', + stage: 'FIRST_RENDERER_READY', + }, + { event: 'desktop.renderer.ready', milestone: 'RENDERER', stage: 'FIRST_RENDERER_READY' }, + ]; + for (const fixture of cases) { + await writeFile(evidence, fixture.event ? `${JSON.stringify({ event: fixture.event })}\n` : ''); + assert.deepEqual(await classifyFirstEvidenceFailure(evidence, 'FAILED_EXIT'), { + milestone: fixture.milestone, + resultClass: 'FAILED_EXIT', + stage: fixture.stage, + }); + } - const operationFailure = new NativeLifecycleOperationFailure('FIRST_SECURE_STORAGE_PROBE', privateFailure); + const classification = await classifyFirstEvidenceFailure(evidence, 'FAILED_EXIT'); + const operationFailure = new NativeLifecycleOperationFailure( + classification.stage, + privateFailure, + classification, + ); const aggregate = new NativeLifecycleFailure(operationFailure, [{ label: 'process-groups', error: new Error('private cleanup output'), }]); - assert.match(aggregate.message, /stage:FIRST_SECURE_STORAGE_PROBE/); + assert.match(aggregate.message, /stage:FIRST_RENDERER_READY/); + assert.match(aggregate.message, /milestone:RENDERER/); + assert.match(aggregate.message, /result:FAILED_EXIT/); assert.doesNotMatch(String(aggregate), /private\/profile|secret\.invalid|private cleanup output/); assert.doesNotMatch(JSON.stringify(aggregate), /private\/profile|secret\.invalid|private cleanup output/); assert.doesNotMatch(inspect(aggregate), /private\/profile|secret\.invalid|private cleanup output/); @@ -259,6 +314,30 @@ describe('native staged artifact lifecycle authority', () => { assert.equal(stale.registered, true); }); + test('registers before dispatching through the exact copied macOS application path', async () => { + const applicationRoot = '/private/copied/ProPR Desktop.app'; + const link = 'propr://connect?api=https%3A%2F%2Ft-native-evidence.propr.dev'; + const calls = []; + const authority = new LaunchServicesAuthority(applicationRoot, { FIXED: 'environment' }, { + runCommand: async (file, args, options) => { + calls.push({ file, args, options }); + return { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) }; + }, + }); + + await assert.rejects(authority.dispatch(link), /must be registered/); + await authority.register(); + await authority.dispatch(link); + + assert.equal(calls[0].args[0], '-f'); + assert.equal(calls[0].args[1], applicationRoot); + assert.deepEqual(calls[1], { + file: '/usr/bin/open', + args: ['-a', applicationRoot, link], + options: { env: { FIXED: 'environment' }, timeout: 15_000 }, + }); + }); + test('retains the copied application until unregister and exact absence both succeed', async () => { for (const failurePoint of ['unregister', 'postcondition']) { const calls = []; diff --git a/apps/desktop/src/deep-link-delivery.test.ts b/apps/desktop/src/deep-link-delivery.test.ts index 6f8b8e91a..71c841463 100644 --- a/apps/desktop/src/deep-link-delivery.test.ts +++ b/apps/desktop/src/deep-link-delivery.test.ts @@ -1,6 +1,10 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { DeepLinkDelivery, type DeepLinkWindow } from './deep-link-delivery'; +import { + DeepLinkDelivery, + deepLinkAcknowledgementTimeoutMs, + type DeepLinkWindow, +} from './deep-link-delivery'; import type { DesktopDeepLinkDelivery } from './shared/contract'; describe('desktop deep-link delivery', () => { @@ -13,6 +17,11 @@ describe('desktop deep-link delivery', () => { }); const tick = () => new Promise(resolve => setImmediate(resolve)); + it('keeps the production acknowledgement deadline while bounding a native-smoke allowance', () => { + assert.equal(deepLinkAcknowledgementTimeoutMs(false), 5_000); + assert.equal(deepLinkAcknowledgementTimeoutMs(true), 15_000); + }); + it('queues across the load boundary and waits for renderer consumption in order', async () => { const sent: DesktopDeepLinkDelivery[] = []; const consumed: string[] = []; diff --git a/apps/desktop/src/deep-link-delivery.ts b/apps/desktop/src/deep-link-delivery.ts index acd3f76dd..ca19319e2 100644 --- a/apps/desktop/src/deep-link-delivery.ts +++ b/apps/desktop/src/deep-link-delivery.ts @@ -4,6 +4,15 @@ import type { DesktopDeepLinkDelivery, } from './shared/contract'; +export const DEFAULT_DEEP_LINK_ACKNOWLEDGEMENT_TIMEOUT_MS = 5_000; +export const NATIVE_SMOKE_DEEP_LINK_ACKNOWLEDGEMENT_TIMEOUT_MS = 15_000; + +export const deepLinkAcknowledgementTimeoutMs = (nativeArtifactSmoke: boolean): number => ( + nativeArtifactSmoke + ? NATIVE_SMOKE_DEEP_LINK_ACKNOWLEDGEMENT_TIMEOUT_MS + : DEFAULT_DEEP_LINK_ACKNOWLEDGEMENT_TIMEOUT_MS +); + export interface DeepLinkWindow { isDestroyed(): boolean; webContents: { @@ -38,7 +47,7 @@ export class DeepLinkDelivery { private readonly failed: (error: Error) => void = () => undefined, private readonly now: () => number = Date.now, private readonly duplicateWindowMs = 1_000, - private readonly acknowledgementTimeoutMs = 5_000, + private readonly acknowledgementTimeoutMs = DEFAULT_DEEP_LINK_ACKNOWLEDGEMENT_TIMEOUT_MS, ) { if (!Number.isFinite(duplicateWindowMs) || duplicateWindowMs < 0 || !Number.isFinite(acknowledgementTimeoutMs) || acknowledgementTimeoutMs <= 0) { diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index af73f7384..de6577a3f 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -4,7 +4,7 @@ import { pathToFileURL } from 'node:url'; import { app, BrowserWindow, ipcMain, net, protocol, safeStorage, screen, session, shell } from 'electron'; import type { Rectangle } from 'electron'; import { DESKTOP_RENDERER_ORIGIN } from '@propr/shared'; -import { DeepLinkDelivery } from './deep-link-delivery'; +import { DeepLinkDelivery, deepLinkAcknowledgementTimeoutMs } from './deep-link-delivery'; import { handleDeepLinkDeliveryFailure } from './deep-link-failure-policy'; import { registerIpcHandlers } from './ipc'; import { LocalLifecycleController } from './lifecycle'; @@ -155,6 +155,9 @@ const deepLinkDelivery = new DeepLinkDelivery( log, }); }, + Date.now, + 1_000, + deepLinkAcknowledgementTimeoutMs(nativeSmokePhase !== undefined), ); let logger: DesktopLogger | null = null; let shutdownStarted = false; diff --git a/apps/desktop/src/release-workflow.test.ts b/apps/desktop/src/release-workflow.test.ts index cb00ae2ff..d716216d0 100644 --- a/apps/desktop/src/release-workflow.test.ts +++ b/apps/desktop/src/release-workflow.test.ts @@ -343,7 +343,7 @@ describe('desktop trusted release workflow', () => { assert.match(nativeArtifactLifecycle, /inspectArtifactArchitecture/); assert.match(nativeArtifactLifecycle, /assertSafeExtractedTree/); assert.match(nativeArtifactLifecycle, /assertProfileAuthority/); - assert.match(nativeArtifactLifecycle, /LaunchServices-registration\+open-bundle-dispatch/); + assert.match(nativeArtifactLifecycle, /LaunchServices-registration\+open-exact-application-dispatch/); assert.match(nativeArtifactLifecycle, /xdg-mime-registration\+gio-dispatch/); assert.doesNotMatch(nativeArtifactLifecycle, /xattr|spctl|--no-sandbox|--disable-sandbox/); });