From 6c35f78e5f3cf983c8da61041947bcae1a8392fa Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 21:42:41 +0000 Subject: [PATCH 1/4] fix(ai): Resolve issue #2122 - Integrate native Mac/Linux artifact lifecycle with Implemented by ProPR AI using gpt-5.6-sol model. Implementation completed successfully. --- .github/workflows/desktop-release-guard.yml | 19 +- apps/desktop/README.md | 75 + apps/desktop/forge.config.ts | 2 + .../packaged-connect-platform.test.mjs | 1 + .../scripts/packaged-smoke-support.mjs | 16 +- .../scripts/packaged-smoke-support.test.mjs | 37 +- .../run-packaged-darwin-connect-smoke.sh | 39 +- .../test-native-artifact-lifecycle.mjs | 1503 +++++++++++++++++ .../test-native-artifact-lifecycle.test.mjs | 489 ++++++ apps/desktop/src/deep-link-delivery.test.ts | 179 +- apps/desktop/src/deep-link-delivery.ts | 157 +- .../src/deep-link-failure-policy.test.ts | 49 + apps/desktop/src/deep-link-failure-policy.ts | 29 + apps/desktop/src/ipc-lifecycle.test.ts | 7 + apps/desktop/src/ipc.test.ts | 25 + apps/desktop/src/ipc.ts | 43 +- apps/desktop/src/main.ts | 360 +++- apps/desktop/src/preload-bridge.test.ts | 48 +- apps/desktop/src/preload-bridge.ts | 46 +- apps/desktop/src/release-workflow.test.ts | 32 + apps/desktop/src/shared/contract.ts | 17 +- apps/desktop/src/shutdown.ts | 6 + apps/desktop/src/smoke-log-path.test.ts | 72 + apps/desktop/src/smoke-log-path.ts | 39 + .../src/smoke-test-authorization.test.ts | 2 +- apps/desktop/src/smoke-test-evidence.test.ts | 14 + apps/desktop/src/smoke-test-evidence.ts | 35 +- propr-ui/src/desktop-deep-link.ts | 37 +- .../src/desktop/DesktopExperience.test.tsx | 21 +- propr-ui/src/desktop/types.ts | 7 +- propr-ui/src/desktop/useDesktopDeepLinks.ts | 38 +- 31 files changed, 3295 insertions(+), 149 deletions(-) create mode 100644 apps/desktop/scripts/test-native-artifact-lifecycle.mjs create mode 100644 apps/desktop/scripts/test-native-artifact-lifecycle.test.mjs create mode 100644 apps/desktop/src/deep-link-failure-policy.test.ts create mode 100644 apps/desktop/src/deep-link-failure-policy.ts create mode 100644 apps/desktop/src/smoke-log-path.test.ts create mode 100644 apps/desktop/src/smoke-log-path.ts diff --git a/.github/workflows/desktop-release-guard.yml b/.github/workflows/desktop-release-guard.yml index 500ba1fd9..d6b8f1e25 100644 --- a/.github/workflows/desktop-release-guard.yml +++ b/.github/workflows/desktop-release-guard.yml @@ -189,7 +189,7 @@ jobs: if: matrix.platform == 'linux' run: | sudo apt-get update - sudo apt-get install --yes cpio dbus-x11 fakeroot gnome-keyring libsecret-1-0 rpm zip + sudo apt-get install --yes cpio dbus-x11 desktop-file-utils fakeroot gnome-keyring libglib2.0-bin libsecret-1-0 rpm unzip xdg-utils xvfb zip - name: Package desktop app from clean checkout shell: bash @@ -315,6 +315,23 @@ 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 + bash apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh \ + "${{ matrix.arch }}" native-lifecycle "$PROPR_DESKTOP_VERSION" \ + "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 c68462b50..640aa6b45 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -124,6 +124,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) + +Choose the artifact whose `x64` or `arm64` suffix matches the machine. These are internal validation builds: they do +not claim signing, notarization, or Gatekeeper approval, and the commands below do not weaken quarantine or trust +policy. An unsigned macOS build may therefore be rejected on a normal end-user machine. + +Debian/Ubuntu DEB installation and native removal: + +```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 +``` + +Fedora/RHEL-family RPM installation, followed by the package-manager-independent ZIP flow: + +```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 Intel (`x64`) or Apple Silicon (`arm64`) macOS, mount and copy the DMG or extract the ZIP. Quit the app before +removing it: + +```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" +sudo 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' +sudo 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 DEB/RPM/ZIP on `ubuntu-24.04` and `ubuntu-24.04-arm`, and DMG/ZIP on +`macos-15-intel` and `macos-15`. Every staged format is extracted or mounted and copied, launched, shut down, +relaunched with the same isolated profile, and removed. It verifies the staged hash remains unchanged, executable +architecture and native launcher registration, profile permissions and state preservation, warm OS protocol dispatch, +renderer exactly-once acknowledgement, explicit confirmation of untrusted Connect candidates, and cleanup of owned +processes, mounts, LaunchServices registration, profiles, and install roots. + +For copied macOS test apps only, CI reuses the packaged-Connect harness to generate one disposable, non-production +code-signing identity in an isolated keychain. It signs the copied app (never the staged DMG/ZIP), verifies the same +designated requirement before and after both launches, and restores the runner's original keychain list/default before +deleting the identity and temporary keychain. This stabilizes the Safe Storage application identity without changing +trust settings and is not evidence of Developer ID signing, notarization, Gatekeeper approval, or end-user launchability. +Linux intentionally withholds the outer session bus from the artifact process: it proves plaintext/basic-text fallback +is refused, but does not claim libsecret custody. Cold launches are direct argv; Linux package warm dispatch uses an +isolated XDG MIME database and `gio`, ZIP warm dispatch is direct because ZIP has no registered launcher, and macOS +warm dispatch uses LaunchServices against the exact copied bundle. + ### 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 363c1df5a..6356e4eda 100644 --- a/apps/desktop/forge.config.ts +++ b/apps/desktop/forge.config.ts @@ -189,6 +189,7 @@ const config: ForgeConfig = { productName: 'ProPR Desktop', version: releaseVersion, bin: DESKTOP_EXECUTABLE_NAME, + mimeType: ['x-scheme-handler/propr'], }, })] : []), @@ -199,6 +200,7 @@ const config: ForgeConfig = { productName: 'ProPR Desktop', version: releaseVersion, bin: DESKTOP_EXECUTABLE_NAME, + mimeType: ['x-scheme-handler/propr'], }, })] : []), diff --git a/apps/desktop/scripts/packaged-connect-platform.test.mjs b/apps/desktop/scripts/packaged-connect-platform.test.mjs index 2e24d3207..995a34886 100644 --- a/apps/desktop/scripts/packaged-connect-platform.test.mjs +++ b/apps/desktop/scripts/packaged-connect-platform.test.mjs @@ -137,6 +137,7 @@ describe('packaged Connect target-native credential setup', () => { 'APPLICATION_SIGNING', 'INITIAL_SIGNATURE_VERIFICATION', 'PAIR_REPROBE_JOURNEY', + 'NATIVE_ARTIFACT_LIFECYCLE', 'STABLE_SIGNATURE_VERIFICATION', 'KEYCHAIN_RESTORATION_DELETION', 'TEMPORARY_FILE_CLEANUP', 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 5e7fba41d..2c32c88c2 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'; @@ -109,6 +109,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('defines four isolated launches with exact per-mode environment, argv, and marker contracts', () => { const firstOrigin = 'http://127.0.0.1:41001'; const secondOrigin = 'http://127.0.0.1:41002'; diff --git a/apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh b/apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh index 955bfbfdb..26d268bd3 100644 --- a/apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh +++ b/apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh @@ -12,6 +12,11 @@ if [[ "$architecture" != 'arm64' && "$architecture" != 'x64' ]]; then echo 'Packaged Darwin Connect acceptance requires an explicit supported architecture.' >&2 exit 1 fi +mode="${2:-connect}" +if [[ "$mode" != 'connect' && "$mode" != 'native-lifecycle' ]]; then + echo 'Packaged Darwin acceptance mode is invalid.' >&2 + exit 1 +fi script_directory="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" repository_root="$(cd "$script_directory/../../.." && pwd -P)" @@ -19,7 +24,10 @@ application="$repository_root/apps/desktop/out/propr-desktop-darwin-$architectur signature_verifier="$script_directory/verify-darwin-packaged-connect-signature.mjs" application_signer="$script_directory/sign-darwin-packaged-connect.mjs" bounded_runner="$script_directory/run-bounded-darwin-command.mjs" -if [[ ! -d "$application" || ! -f "$signature_verifier" || ! -f "$application_signer" +native_lifecycle="$script_directory/test-native-artifact-lifecycle.mjs" +if [[ ( "$mode" == 'connect' && ! -d "$application" ) + || ( "$mode" == 'native-lifecycle' && ! -f "$native_lifecycle" ) + || ! -f "$signature_verifier" || ! -f "$application_signer" || ! -f "$bounded_runner" ]]; then echo 'Packaged Darwin Connect acceptance artifact is missing.' >&2 exit 1 @@ -30,6 +38,7 @@ readonly COMMAND_TIMEOUT_MS=30000 readonly CLEANUP_TIMEOUT_MS=10000 readonly SIGNING_TIMEOUT_MS=180000 readonly JOURNEY_TIMEOUT_MS=240000 +readonly NATIVE_LIFECYCLE_TIMEOUT_MS=1200000 readonly TERMINATION_GRACE_MS=5000 readonly MAX_OUTPUT_BYTES=262144 @@ -37,7 +46,7 @@ stage_marker() { local stage="$1" local code="$2" case "$stage" in - KEY_CERTIFICATE_GENERATION|KEYCHAIN_CREATION_SELECTION|IDENTITY_IMPORT|PARTITION_LIST_UPDATE|APPLICATION_SIGNING|INITIAL_SIGNATURE_VERIFICATION|PAIR_REPROBE_JOURNEY|STABLE_SIGNATURE_VERIFICATION|KEYCHAIN_RESTORATION_DELETION|TEMPORARY_FILE_CLEANUP) ;; + KEY_CERTIFICATE_GENERATION|KEYCHAIN_CREATION_SELECTION|IDENTITY_IMPORT|PARTITION_LIST_UPDATE|APPLICATION_SIGNING|INITIAL_SIGNATURE_VERIFICATION|PAIR_REPROBE_JOURNEY|NATIVE_ARTIFACT_LIFECYCLE|STABLE_SIGNATURE_VERIFICATION|KEYCHAIN_RESTORATION_DELETION|TEMPORARY_FILE_CLEANUP) ;; *) return 1 ;; esac case "$code" in @@ -249,12 +258,30 @@ verify_stable_signature() { "$application" "$identity_sha1" "$requirement_proof" "$keychain_path" } +run_native_artifact_lifecycle() { + local version="${1:-}" + local artifact_directory="${2:-}" + if [[ ! "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ || ! -d "$artifact_directory" ]]; then + echo 'Native Darwin lifecycle arguments are invalid.' >&2 + return 1 + fi + PROPR_DESKTOP_NATIVE_SIGNING_KEYCHAIN="$keychain_path" \ + PROPR_DESKTOP_NATIVE_SIGNING_CERTIFICATE_SHA1="$identity_sha1" \ + run_bounded_forward "$NATIVE_LIFECYCLE_TIMEOUT_MS" node "$native_lifecycle" \ + --version "$version" --platform darwin --arch "$architecture" \ + --artifact-directory "$artifact_directory" +} + run_stage KEY_CERTIFICATE_GENERATION generate_key_and_certificates run_stage KEYCHAIN_CREATION_SELECTION create_and_select_keychain run_stage IDENTITY_IMPORT import_identity run_stage PARTITION_LIST_UPDATE update_partition_list unset keychain_password identity_password -run_stage APPLICATION_SIGNING sign_application -run_stage INITIAL_SIGNATURE_VERIFICATION verify_initial_signature -run_stage PAIR_REPROBE_JOURNEY run_pair_and_reprobe -run_stage STABLE_SIGNATURE_VERIFICATION verify_stable_signature +if [[ "$mode" == 'connect' ]]; then + run_stage APPLICATION_SIGNING sign_application + run_stage INITIAL_SIGNATURE_VERIFICATION verify_initial_signature + run_stage PAIR_REPROBE_JOURNEY run_pair_and_reprobe + run_stage STABLE_SIGNATURE_VERIFICATION verify_stable_signature +else + run_stage NATIVE_ARTIFACT_LIFECYCLE run_native_artifact_lifecycle "${3:-}" "${4:-}" +fi 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..950e15175 --- /dev/null +++ b/apps/desktop/scripts/test-native-artifact-lifecycle.mjs @@ -0,0 +1,1503 @@ +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 { 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'; +import { fileURLToPath } from 'node:url'; +import { + createHeldDmgArtifact, + inspectArtifactArchitecture, + inspectExecutableBytes, +} from './release-architecture.mjs'; +import { + createPrivateSmokeProfile, + createSmokeChildEnvironment, + removePrivateSmokeProfile, +} from './packaged-smoke-support.mjs'; +import { signDarwinPackagedConnectApplication } from './sign-darwin-packaged-connect.mjs'; +import { verifyDarwinPackagedConnectSignature } from './verify-darwin-packaged-connect-signature.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 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'; +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_probe.started', + 'desktop.native.secure_storage_probe.completed', + '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); + 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 => { + 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({ stderr, stderrOverflow, stdout, stdoutOverflow }); + }); +}); + +const delay = milliseconds => new Promise(resolveDelay => setTimeout(resolveDelay, milliseconds)); + +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_DARWIN_ACCEPTANCE_SIGNATURE', + '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', + 'LS_REGISTER', + 'OPEN_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_DARWIN_SIGNATURE_VALIDATION', + '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, evidenceClassification) { + if (!NATIVE_LIFECYCLE_OPERATION_STAGES.includes(stage)) { + throw new Error('Native lifecycle failure stage is invalid'); + } + 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 }); + } +} + +export class NativeLifecycleFailure extends AggregateError { + constructor(primaryError, cleanupFailures) { + const cleanupLabels = cleanupFailures.map(failure => failure.label).sort(); + 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${classification}; cleanup also failed: ${cleanupLabels.join(', ')}` + : `Native lifecycle cleanup failed: ${cleanupLabels.join(', ')}`; + const safeErrors = [ + ...(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); + 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; +}; + +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 (!await predicate()) return true; + await delay(25); + } + return !await 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; + } + 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( + async () => (await inspectRunningProcessGroupMembers(this.pid)).length > 0, + CLEANUP_GRACE_MS, + )) { + this.signal('SIGKILL'); + } + } + const groupGone = await waitUntil( + async () => (await inspectRunningProcessGroupMembers(this.pid)).length > 0, + 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 ((await inspectRunningProcessGroupMembers(this.pid)).length > 0) { + throw new Error('Native application left a running 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); + } + 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(); + } 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 (resultError) throw resultError; + } +} + +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 }) => { + 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; +}; + +export 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'); + } + 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()) { + throw new Error('Native artifact contains an unsupported filesystem entry'); + } + } + }; + await visit(root); +}; + +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 }) => { + 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 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') { + 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 extractDmg({ artifact, installRoot, mountAuthority }); + } +}; + +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 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; + const hasRequiredEvents = async () => { + const names = await readFixedEvidenceEvents(path); + return events.every(event => names.includes(event)); + }; + while (true) { + try { + if (await hasRequiredEvents()) return; + } catch (error) { + if (error?.code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error; + } + 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 NativeLifecycleEvidenceWaitFailure('EVIDENCE_DEADLINE'); +}; + +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'); + } + previous = index; + } +}; + +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, 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, processGroups }) => { + if (!application.desktopFile) { + await dispatchDirect(application, profile.userData, link, env, processGroups); + 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 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 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 }); + } + + 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; + } +} + +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 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 assertProfileAuthority = async profile => { + const desktop = join(profile.userData, 'desktop'); + const state = join(desktop, 'profiles.json'); + 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() + || !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'); + let persisted; + try { + persisted = JSON.parse(contents); + } catch { + throw new Error('Native profile state is not valid JSON'); + } + if (persisted?.version !== 3 + || !persisted.credentialSlots || Object.keys(persisted.credentialSlots).length !== 0 + || !persisted.credentialEpochs || Object.keys(persisted.credentialEpochs).length !== 0 + || !persisted.pendingRevocations || Object.keys(persisted.pendingRevocations).length !== 0 + || /native-custody-probe|propr_it_/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}'); + }); + 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 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 => { + 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; + } + } +}; + +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 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 { + // Only fixed classifications may cross the native-gate diagnostic boundary. + } + 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 }) => { + const workRoot = await mkdtemp(join(tmpdir(), `propr-native-${kind}-`)); + const installRoot = join(workRoot, 'install'); + const mountRoot = join(workRoot, 'mount'); + const processGroups = new OwnedProcessGroups(); + const mountAuthority = kind === 'dmg' ? new DmgMountAuthority(mountRoot) : null; + let profile; + let profileApi; + let application; + let launchServices; + let sandboxPrepared = false; + let primaryError; + let evidenceClassification; + 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); + 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'; + 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 }); + let darwinSignatureProof; + if (target.platform === 'darwin') { + operationStage = 'PREPARE_DARWIN_ACCEPTANCE_SIGNATURE'; + const keychain = process.env.PROPR_DESKTOP_NATIVE_SIGNING_KEYCHAIN; + const certificateSha1 = process.env.PROPR_DESKTOP_NATIVE_SIGNING_CERTIFICATE_SHA1; + if (!keychain?.endsWith('.keychain-db') || !/^[A-F0-9]{40}$/.test(certificateSha1 ?? '')) { + throw new Error('Native Darwin lifecycle requires the CI-only acceptance signing identity'); + } + darwinSignatureProof = join(workRoot, 'designated-requirement.txt'); + await signDarwinPackagedConnectApplication({ + application: application.applicationRoot, + keychain, + certificateSha1, + }); + await verifyDarwinPackagedConnectSignature({ + mode: 'establish', + application: application.applicationRoot, + expectedCertificateSha1: certificateSha1, + proofPath: darwinSignatureProof, + keychain, + }); + } + 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, + 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; + + 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'); + operationStage = 'FIRST_INITIAL_EVIDENCE'; + try { + await waitForEvents(firstEvidence, ['desktop.renderer.ready', 'desktop.deeplink.cold_manual_once'], first.child); + } catch (error) { + if (error instanceof NativeLifecycleEvidenceWaitFailure) { + evidenceClassification = await classifyFirstEvidenceFailure(firstEvidence, error.resultClass); + operationStage = evidenceClassification.stage; + } + 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); + } + 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'; + 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, + `propr://connect?api=https%3A%2F%2Ft-native-evidence.propr.dev%2F${'a'.repeat(2_100)}`, + dispatchEnvironment, + processGroups, + ); + operationStage = 'OVERSIZED_EVIDENCE'; + await waitForEvents(firstEvidence, ['desktop.deeplink.rejected_oversized'], first.child); + operationStage = 'UNSAFE_SCHEME_DISPATCH'; + await dispatchDirect( + application, + profile.userData, + 'https://native-evidence.invalid/unsafe', + 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_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); + + 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, + }); + 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, + { exitCode: null }, + ); + await assertEvidenceOrdering(relaunchEvidence, REQUIRED_RELAUNCH_EVENTS); + await assertProfileAuthority(profile); + if (target.platform === 'darwin') { + operationStage = 'FINAL_DARWIN_SIGNATURE_VALIDATION'; + await verifyDarwinPackagedConnectSignature({ + mode: 'stable', + application: application.applicationRoot, + expectedCertificateSha1: process.env.PROPR_DESKTOP_NATIVE_SIGNING_CERTIFICATE_SHA1, + proofPath: darwinSignatureProof, + keychain: process.env.PROPR_DESKTOP_NATIVE_SIGNING_KEYCHAIN, + }); + } + operationStage = 'FINAL_VALIDATION'; + if (await digest(artifact) !== beforeDigest) throw new Error('Native lifecycle mutated the staged artifact bytes'); + await assertDefaultUserDataUntouched(target); + 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 = new NativeLifecycleOperationFailure( + operationStage, + errorFrom(error, 'Native lifecycle operation failed'), + evidenceClassification, + ); + } + + 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 (mountAuthority?.mounted) await cleanup('dmg-mount', () => mountAuthority.detach()); + if (processGroupAbsenceWasProved(cleanupFailures) && sandboxPrepared && application) { + await cleanup('linux-sandbox', () => run('/usr/bin/sudo', [ + '/bin/rm', '-f', join(application.applicationRoot, 'chrome-sandbox'), + ])); + } + 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 finalCleanupFailures = await removeLifecycleRootsWithAuthority({ + cleanupFailures, + installRoot, + launchServices, + workRoot, + }); + throwCombined(primaryError, finalCleanupFailures); +}; + +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' + ? '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.', + })); +}; + +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..581dd4f0f --- /dev/null +++ b/apps/desktop/scripts/test-native-artifact-lifecycle.test.mjs @@ -0,0 +1,489 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { chmod, mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, test } from 'node:test'; +import { inspect } from 'node:util'; +import { + assertArtifactSet, + assertSafeExtractedTree, + classifyFirstEvidenceFailure, + closeProfileApi, + DmgMountAuthority, + extractDmg, + extractRpm, + inspectRunningProcessGroupMembers, + LaunchServicesAuthority, + NativeLifecycleEvidenceWaitFailure, + NativeLifecycleFailure, + NativeLifecycleOperationFailure, + OwnedProcessGroups, + parseArguments, + removeCopiedApplicationWithLaunchServicesAuthority, + removeLifecycleRootsWithAuthority, + removeAuthorizedProfile, + runningProcessGroupMembersFromPs, + waitForEvents, +} from './test-native-artifact-lifecycle.mjs'; + +describe('native staged artifact lifecycle authority', () => { + test('signs only copied Darwin apps with the shared disposable identity and verifies stability', async () => { + const source = await readFile(new URL('./test-native-artifact-lifecycle.mjs', import.meta.url), 'utf8'); + assert.match(source, /signDarwinPackagedConnectApplication/); + assert.match(source, /verifyDarwinPackagedConnectSignature/); + assert.match(source, /mode: 'establish'[\s\S]*mode: 'stable'/u); + assert.match(source, /PROPR_DESKTOP_NATIVE_SIGNING_KEYCHAIN/); + assert.match(source, /beforeDigest[\s\S]*digest\(artifact\) !== beforeDigest/u); + assert.doesNotMatch(source, /add-trusted-cert|remove-trusted-cert|xattr|spctl/u); + }); + + 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 }); + } + }); + + 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 }); + } + }); + + 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 () => { + 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 = []; + 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('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 { + 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 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_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/); + } 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, {}, { + 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('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 = []; + 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('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, + 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 099fc4755..fe2a7079c 100644 --- a/apps/desktop/src/deep-link-delivery.test.ts +++ b/apps/desktop/src/deep-link-delivery.test.ts @@ -1,63 +1,168 @@ 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 { + DeepLinkDelivery, + deepLinkAcknowledgementTimeoutMs, + 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('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[] = []; + 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.deliver('propr://open?path=%2Ftasks'); + delivery.setWindow(window); - delivery.didFinishLoad(window); - delivery.deliver('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(sent, []); + assert.deepEqual(consumed, [ + 'propr://connect?api=http%3A%2F%2Flocalhost%3A4000', + 'propr://open?path=%2Ftasks', + ]); + }); + 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 => { consumed.push(value); }, + error => { throw error; }, + () => now, + 1_000, + ); + const window = createWindow(sent); 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(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]); }); - 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); + 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/); + }); - const sent: Array<{ channel: string; value: string }> = []; + it('cancels pending acknowledgement work during coordinated shutdown', async () => { + const sent: DesktopDeepLinkDelivery[] = []; + let failed = false; + const delivery = new DeepLinkDelivery( + 'desktop:deep-link', + [], + undefined, + () => { failed = true; }, + ); const window = createWindow(sent); - const connectUrl = 'propr://connect?api=https%3A%2F%2Fconnect.propr.dev'; - const delivery = new DeepLinkDelivery('desktop:deep-link', [connectUrl]); + delivery.setWindow(window); + assert.equal(delivery.deliver('propr://open?path=%2Ftasks'), true); + assert.equal(sent.length, 1); - delivery.didFinishLoad(window); - assert.deepEqual(sent, []); + delivery.close(); + await delivery.whenIdle(); - delivery.setWindow(window); - const assertPackagedSmokeDeepLink = () => { - assert.deepEqual(sent.filter(({ value }) => value === connectUrl), [ - { channel: 'desktop:deep-link', value: connectUrl }, - ]); - }; - assertPackagedSmokeDeepLink(); + assert.equal(failed, false); + assert.equal(delivery.deliver('propr://open?path=%2Fplans'), false); + assert.equal(delivery.acknowledge(window, { + ...sent[0], + consumption: { kind: 'open-queued', target: '/tasks' }, + }), false); + }); - delivery.didFinishLoad(window); + 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, + undefined, + () => 10, + ); + const window = createWindow(sent); + assert.equal(delivery.deliver(link), false); delivery.setWindow(window); - - assert.equal(sent.filter(({ value }) => value === connectUrl).length, 1); + 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 aaf9ead26..19ee85169 100644 --- a/apps/desktop/src/deep-link-delivery.ts +++ b/apps/desktop/src/deep-link-delivery.ts @@ -1,26 +1,78 @@ +import type { + DesktopDeepLinkAcknowledgement, + DesktopDeepLinkConsumption, + 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: { isLoading(): boolean; - send(channel: string, value: string): void; + send(channel: string, value: DesktopDeepLinkDelivery): void; }; } /** Coordinates protocol delivery across the window creation/load boundary. */ export class DeepLinkDelivery { private window: TWindow | null = null; + private readonly recentlyAccepted = new Map(); + private deliveryId = 0; + private draining = false; + private closed = false; + private active: { + acknowledged: boolean; + delivery: DesktopDeepLinkDelivery; + resolve: (consumption: DesktopDeepLinkConsumption) => void; + reject: (error: Error) => 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, + 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 = DEFAULT_DEEP_LINK_ACKNOWLEDGEMENT_TIMEOUT_MS, + ) { + 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); + const acceptedAt = this.now(); + uniquePending.forEach(value => this.recentlyAccepted.set(value, acceptedAt)); + } - deliver(value: string): void { - if (!this.window || this.window.isDestroyed() || this.window.webContents.isLoading()) { - this.pending.push(value); - return; + deliver(value: string): boolean { + if (this.closed) return false; + 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); } - this.window.webContents.send(this.channel, value); + this.pending.push(value); + void this.drain(); + return true; } didFinishLoad(window: TWindow): void { @@ -29,16 +81,97 @@ 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 => window.webContents.send(this.channel, 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); + } + + whenIdle(): Promise { + if (!this.draining && !this.active && this.pending.length === 0) return Promise.resolve(); + return new Promise(resolve => this.idleWaiters.add(resolve)); + } + + close(): void { + if (this.closed) return; + this.closed = true; + this.pending.splice(0); + this.active?.reject(new Error('Desktop deep-link delivery closed during shutdown')); + if (!this.draining && !this.active) { + this.idleWaiters.forEach(resolve => resolve()); + this.idleWaiters.clear(); + } + } + + 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, + reject: rejectAcknowledgement, + 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); + if (!this.closed) { + 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/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/ipc-lifecycle.test.ts b/apps/desktop/src/ipc-lifecycle.test.ts index bf5b2a599..b57a334ab 100644 --- a/apps/desktop/src/ipc-lifecycle.test.ts +++ b/apps/desktop/src/ipc-lifecycle.test.ts @@ -828,6 +828,10 @@ describe('desktop IPC shutdown gate', () => { const shutdown = createDesktopShutdownCoordinator({ credentials: { dispose: async () => { order.push('credentials-dispose'); } }, lifecycle: { shutdown: async () => { order.push('lifecycle-shutdown'); } }, + deepLinks: { + close: () => { order.push('deep-links-close'); }, + whenIdle: async () => { order.push('deep-links-drain'); }, + }, ipc: { close: () => { order.push('ipc-close'); registered.close(); }, awaitIdle: () => { order.push('ipc-drain'); return registered.awaitIdle(); }, @@ -858,6 +862,9 @@ describe('desktop IPC shutdown gate', () => { await shutdown.awaitFinished(); assert.equal(handlers.size, 0); + assert.equal(order.indexOf('deep-links-close') > order.indexOf('shutdown-started'), true); + assert.equal(order.indexOf('deep-links-close') < order.indexOf('ipc-close'), true); + assert.equal(order.indexOf('deep-links-drain') > order.indexOf('ipc-close'), true); assert.equal(order.indexOf('profiles-close') > order.indexOf('ipc-drain'), true); assert.equal(order.indexOf('session-dispose') > order.indexOf('profiles-close'), true); assert.deepEqual(order.slice(-3), ['ipc-dispose', 'window-destroy', 'app-quit']); diff --git a/apps/desktop/src/ipc.test.ts b/apps/desktop/src/ipc.test.ts index fea2b754e..45eb9799c 100644 --- a/apps/desktop/src/ipc.test.ts +++ b/apps/desktop/src/ipc.test.ts @@ -2,8 +2,33 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import type { Session } from 'electron'; import { clearDesktopInstanceCookies, logoutDesktopSession } from './desktop-session'; +import { isValidDesktopDeepLinkAcknowledgement } from './ipc'; describe('desktop session IPC operations', () => { + it('accepts only acknowledgements semantically bound to the delivered deep link', () => { + const connect = { + deliveryId: 1, + url: 'propr://connect?api=https%3A%2F%2Fconnect.propr.dev', + consumption: { kind: 'connect-confirmation', target: 'https://connect.propr.dev' }, + }; + const open = { + deliveryId: 2, + url: 'propr://open?path=%2Ftasks%3Fstatus%3Dopen', + consumption: { kind: 'open-queued', target: '/tasks?status=open' }, + }; + assert.equal(isValidDesktopDeepLinkAcknowledgement(connect), true); + assert.equal(isValidDesktopDeepLinkAcknowledgement(open), true); + assert.equal(isValidDesktopDeepLinkAcknowledgement({ + ...connect, + consumption: { kind: 'connect-confirmation', target: 'https://attacker.example' }, + }), false); + assert.equal(isValidDesktopDeepLinkAcknowledgement({ + ...open, + consumption: { kind: 'open-navigated', target: '/plans' }, + }), false); + assert.equal(isValidDesktopDeepLinkAcknowledgement({ ...connect, extra: true }), false); + }); + it('logs out through the active Electron session with credentials and without following redirects', async () => { const requests: Array<{ url: string; init: RequestInit | undefined }> = []; const desktopSession: Pick = { diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index 924421b26..e9fa6c498 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -5,9 +5,14 @@ import type { DesktopConnectDiscoveryService } from './connect-discovery'; import type { DesktopLogger } from './logger'; import type { LocalLifecycleController } from './lifecycle'; import type { ProfileStore } from './profile-store'; -import { isSafeExternalUrl, isTrustedRendererUrl } from './security'; +import { + connectApiBaseUrlFromDeepLink, + dashboardPathFromDeepLink, + isSafeExternalUrl, + isTrustedRendererUrl, +} from './security'; import { IPC_CHANNELS } from './shared/contract'; -import type { DesktopAcceptanceJourneyStage } from './shared/contract'; +import type { DesktopAcceptanceJourneyStage, DesktopDeepLinkAcknowledgement } from './shared/contract'; export type DesktopAcceptanceOperation = 'PROFILE_SAVE' | 'PAIR' | 'PROBE' | 'ACTIVATE'; export type DesktopAcceptanceOperationStatus = @@ -30,6 +35,7 @@ interface RegisterIpcOptions { devServerUrl: string | undefined; packagedRendererUrl: string; openExternal(url: string): Promise; + acknowledgeDeepLink?(event: IpcMainInvokeEvent, acknowledgement: DesktopDeepLinkAcknowledgement): boolean; onRendererActiveProfileChanged?(origin: string | null): void; /** @internal Deterministic admitted-work accounting for lifecycle proof. */ observeInvocation?(phase: 'entry' | 'exit', channel: string): void; @@ -78,6 +84,31 @@ const acceptanceStatus = (result: unknown): DesktopAcceptanceOperationStatus => return 'COMPLETED'; }; +export const isValidDesktopDeepLinkAcknowledgement = ( + value: unknown, +): value is DesktopDeepLinkAcknowledgement => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const acknowledgement = value as Record; + if (Object.keys(acknowledgement).some(key => !['deliveryId', 'url', 'consumption'].includes(key)) + || !Number.isSafeInteger(acknowledgement.deliveryId) + || (acknowledgement.deliveryId as number) <= 0 + || typeof acknowledgement.url !== 'string' + || !acknowledgement.consumption || typeof acknowledgement.consumption !== 'object' + || Array.isArray(acknowledgement.consumption)) return false; + const consumption = acknowledgement.consumption as Record; + if (Object.keys(consumption).some(key => !['kind', 'target'].includes(key)) + || !['connect-confirmation', 'open-queued', 'open-navigated'].includes(consumption.kind as string) + || typeof consumption.target !== 'string' || consumption.target.length === 0 + || consumption.target.length > 2_048) return false; + const expectedConnectTarget = connectApiBaseUrlFromDeepLink(acknowledgement.url); + const expectedOpenTarget = dashboardPathFromDeepLink(acknowledgement.url); + return expectedConnectTarget !== null + ? consumption.kind === 'connect-confirmation' && consumption.target === expectedConnectTarget + : expectedOpenTarget !== null + && (consumption.kind === 'open-queued' || consumption.kind === 'open-navigated') + && consumption.target === expectedOpenTarget; +}; + export const registerIpcHandlers = (options: RegisterIpcOptions): RegisteredIpcHandlers => { const channels = new Set(); const active = new Set>(); @@ -140,6 +171,14 @@ export const registerIpcHandlers = (options: RegisterIpcOptions): RegisteredIpcH arch: process.arch, packaged: options.app.isPackaged, })); + handle(IPC_CHANNELS.deepLinkAcknowledgement, (event, acknowledgement, ...args) => { + if (args.length || !isValidDesktopDeepLinkAcknowledgement(acknowledgement)) { + throw new Error('Invalid desktop deep-link acknowledgement'); + } + if (!options.acknowledgeDeepLink?.(event, acknowledgement)) { + throw new Error('Unexpected desktop deep-link acknowledgement'); + } + }); 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 480f93474..ced737de0 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -15,7 +15,8 @@ import { discoverConfiguredConnect, } from '@propr/cli/desktop-discovery'; import { DesktopConnectDiscoveryService } from './connect-discovery'; -import { DeepLinkDelivery } from './deep-link-delivery'; +import { DeepLinkDelivery, deepLinkAcknowledgementTimeoutMs } from './deep-link-delivery'; +import { handleDeepLinkDeliveryFailure } from './deep-link-failure-policy'; import { clearDesktopInstanceCookies } from './desktop-session'; import { DesktopCredentialService, type DesktopPairingBrowserRequest } from './credential-service'; import { @@ -48,10 +49,12 @@ import { DESKTOP_PROTOCOL, IPC_CHANNELS, type DesktopAcceptanceJourneyStage, + 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 { configureDesktopSessionSecurity, type DesktopNetworkPermissionEvidence, @@ -75,6 +78,12 @@ const PACKAGED_CONNECT_JOURNEY_STAGE_EVENT = 'desktop.renderer.connect_journey.s const PACKAGED_CONNECT_JOURNEY_FAILURE_EVENT = 'desktop.renderer.connect_journey.failure'; const PACKAGED_CONNECT_JOURNEY_OPERATION_EVENT = 'desktop.renderer.connect_journey.operation'; const PACKAGED_CONNECT_RENDERER_OWNERSHIP_EVENT = 'desktop.renderer.connect_request_ownership'; +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'; type PackagedConnectJourneyStage = | 'JOURNEY_DISCOVERY_RENDERER' | 'JOURNEY_DISCOVERY_VALIDATED' @@ -117,6 +126,7 @@ const packagedRendererRoot = join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAM 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, @@ -126,12 +136,25 @@ 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); + configureNativeSmokeLogsPath({ + app, + authorizedNativeSmoke: nativeSmokePhase !== undefined, + platform: process.platform, + userDataDirectory: packagedSmokeUserDataDirectory, + }); + packagedSmokeEvidence = createPackagedSmokeEvidenceSink(packagedSmokeUserDataDirectory, nativeSmokePhase); packagedSmokeEvidence?.write('desktop.smoke.authorized'); } } catch { @@ -139,11 +162,12 @@ try { } const packagedSmokeTest = packagedSmokeUserDataDirectory !== null; let mainWindow: BrowserWindow | null = null; +let nativeSmokeWindow: BrowserWindow | null = null; const initialDeepLink = deepLinkFromArguments(process.argv); -const deepLinkDelivery = new DeepLinkDelivery( - IPC_CHANNELS.deepLink, - initialDeepLink ? [initialDeepLink] : [], -); +const nativeObservedEvents = new Set(); +let nativeRendererReady = false; +let nativeCompletionStarted = false; +let nativeProfiles: ProfileStore | null = null; let logger: DesktopLogger | null = null; let shutdownStarted = false; if (process.platform === 'win32') { @@ -250,6 +274,179 @@ const log = (level: 'debug' | 'info' | 'warn' | 'error', event: string, fields?: } }; +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 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 (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'); + } +}; + +const deepLinkDelivery = new DeepLinkDelivery( + IPC_CHANNELS.deepLink, + initialDeepLink ? [initialDeepLink] : [], + (value, consumption, window) => { + assertNativeRendererConsumption(value, consumption, window); + const event = nativeEventForDeliveredLink(value); + if (event) recordNativeEvent(event); + maybeCompleteNativeFirstLaunch(); + }, + () => { + handleDeepLinkDeliveryFailure(nativeSmokePhase !== undefined, { + exit: code => app.exit(code), + log, + }); + }, + Date.now, + 1_000, + deepLinkAcknowledgementTimeoutMs(nativeSmokePhase !== undefined), +); + +const runNativeSecureStorageProbe = async (): Promise => { + if (nativeSmokePhase !== 'first' || !nativeProfiles) return; + recordNativeEvent('desktop.native.secure_storage_probe.started'); + const storage = nativeProfiles.security(); + const credential = { + version: 2 as const, + profileId: 'native-local', + origin: 'http://localhost:44221', + publicInstanceIdentity: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + token: `propr_it_${'a'.repeat(43)}`, + }; + const credentialWrite = await nativeProfiles.writeCredential(credential); + if (process.platform === 'linux') { + if (storage.available || credentialWrite.stored + || await nativeProfiles.readCredential('native-local') !== null) { + throw new Error('Native Linux fallback-only proof unexpectedly claimed libsecret custody'); + } + recordNativeEvent('desktop.native.secure_storage_fallback_refused'); + } else if (storage.available) { + const stored = await nativeProfiles.readCredential('native-local'); + if (storage.backend === 'basic_text' || !credentialWrite.stored + || stored?.token !== credential.token || stored.origin !== credential.origin) { + throw new Error('Native secure-storage custody probe did not use OS encryption'); + } + await nativeProfiles.removeCredential('native-local'); + if (await nativeProfiles.readCredential('native-local') !== null) { + throw new Error('Native secure-storage custody probe cleanup failed'); + } + const pending = await nativeProfiles.pendingRevocations(); + if (pending.length !== 1 || pending[0].credential.token !== credential.token + || !await nativeProfiles.completePendingRevocation( + pending[0].id, + pending[0].credential, + pending[0].credentialGeneration, + )) { + throw new Error('Native secure-storage custody probe cleanup failed'); + } + } else if (credentialWrite.stored || await nativeProfiles.readCredential('native-local') !== null) { + 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_probe.completed'); + recordNativeEvent('desktop.native.secure_storage_enforced'); +}; + +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 input = Array.from(document.querySelectorAll('label')).find(label => + label.textContent?.includes('Instance URL'))?.querySelector('input'); + if (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 !== 'native-local' || 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 reportPackagedConnectJourneyStage = ( code: PackagedConnectJourneyStage, evidence: { storageBackend: 'gnome_libsecret' | 'os-protected' } | undefined = undefined, @@ -1036,7 +1233,32 @@ const createMainWindow = async ( 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 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 input = Array.from(document.querySelectorAll('label')).find(label => + label.textContent?.includes('Instance URL'))?.querySelector('input'); + if (input?.value === ${JSON.stringify(expectedInitialApi)}) return true; + await new Promise(resolve => setTimeout(resolve, 25)); + } while (performance.now() < deadline); + return false; + })()`); + if (!initialEndpointVisible) throw new Error('Native cold deep link did not reach the confirmation UI'); + if (nativeSmokePhase === 'first') { + await runNativeSecureStorageProbe(); + recordNativeEvent('desktop.native.profile_fresh'); + } else { + recordNativeEvent('desktop.native.profile_preserved'); + recordNativeEvent('desktop.deeplink.confirmation_required'); + } + } const smokeProfileApiUrl = process.env.PROPR_DESKTOP_SMOKE_PROFILE_API_URL; if (packagedSmokeTest && !transportSmoke && smokeProfileApiUrl) { const normalizedSmokeApiUrl = normalizeApiBaseUrl(smokeProfileApiUrl); @@ -1063,43 +1285,7 @@ const createMainWindow = async ( log('info', 'desktop.renderer.profile_api.ready', { origin: DESKTOP_RENDERER_ORIGIN }); } let mvpFlowProof: Record = { connectDiscovery: true }; - if (packagedSmokeTest && !transportSmoke && !connectJourney) { - const profileFlow = await window.webContents.executeJavaScript(`(async () => { - const bridge = window.proprDesktop; - const local = await bridge.profiles.save({ label: 'Local setup', apiBaseUrl: 'http://localhost:4000' }); - const remote = await bridge.profiles.save({ label: 'ProPR Connect', apiBaseUrl: 'https://connect.propr.dev' }); - await bridge.profiles.setActive(remote.id); - const profiles = await bridge.profiles.list(); - const lifecycle = await bridge.lifecycle.start(); - const deadline = performance.now() + 2000; - let connectDeepLink = false; - do { - const labels = Array.from(document.querySelectorAll('.desktop-welcome-card form > label')); - connectDeepLink = labels[1]?.querySelector('input')?.value === 'https://connect.propr.dev'; - if (connectDeepLink) break; - await new Promise(resolve => setTimeout(resolve, 25)); - } while (performance.now() < deadline); - return { - active: profiles.activeProfileId === remote.id, - local: profiles.profiles.some(profile => profile.id === local.id && profile.apiBaseUrl === 'http://localhost:4000'), - remote: profiles.profiles.some(profile => profile.id === remote.id && profile.apiBaseUrl === 'https://connect.propr.dev'), - lifecycleBoundary: lifecycle.ok === false && lifecycle.code === 'not-implemented', - connectDeepLink, - }; - })()`); - if (!profileFlow?.active || !profileFlow?.local || !profileFlow?.remote - || !profileFlow?.lifecycleBoundary || !profileFlow?.connectDeepLink) { - throw new Error('Packaged desktop local/remote/API profile flow failed'); - } - mvpFlowProof = { - connectDiscovery: true, - localProfile: profileFlow.local, - remoteActiveProfile: profileFlow.active && profileFlow.remote, - lifecycleBoundary: profileFlow.lifecycleBoundary, - connectUiPopulated: profileFlow.connectDeepLink, - }; - await closePackagedProfileEditorAndWaitForWelcomeChooser(window); - } else if (packagedSmokeTest) { + const assertPackagedMvpBoundary = async (): Promise => { const boundary = await window.webContents.executeJavaScript(`(async () => { const bridge = window.proprDesktop; const metadata = await bridge.app.getMetadata(); @@ -1114,6 +1300,49 @@ const createMainWindow = async ( if (!boundary?.packaged || !boundary?.profiles || !boundary?.lifecycleBoundary) { throw new Error('Packaged desktop transport smoke did not preserve the MVP bridge boundaries'); } + }; + if (packagedSmokeTest && !transportSmoke && !connectJourney) { + if (nativeSmokePhase) { + await assertPackagedMvpBoundary(); + } else { + const profileFlow = await window.webContents.executeJavaScript(`(async () => { + const bridge = window.proprDesktop; + const local = await bridge.profiles.save({ label: 'Local setup', apiBaseUrl: 'http://localhost:4000' }); + const remote = await bridge.profiles.save({ label: 'ProPR Connect', apiBaseUrl: 'https://connect.propr.dev' }); + await bridge.profiles.setActive(remote.id); + const profiles = await bridge.profiles.list(); + const lifecycle = await bridge.lifecycle.start(); + const deadline = performance.now() + 2000; + let connectDeepLink = false; + do { + const labels = Array.from(document.querySelectorAll('.desktop-welcome-card form > label')); + connectDeepLink = labels[1]?.querySelector('input')?.value === 'https://connect.propr.dev'; + if (connectDeepLink) break; + await new Promise(resolve => setTimeout(resolve, 25)); + } while (performance.now() < deadline); + return { + active: profiles.activeProfileId === remote.id, + local: profiles.profiles.some(profile => profile.id === local.id && profile.apiBaseUrl === 'http://localhost:4000'), + remote: profiles.profiles.some(profile => profile.id === remote.id && profile.apiBaseUrl === 'https://connect.propr.dev'), + lifecycleBoundary: lifecycle.ok === false && lifecycle.code === 'not-implemented', + connectDeepLink, + }; + })()`); + if (!profileFlow?.active || !profileFlow?.local || !profileFlow?.remote + || !profileFlow?.lifecycleBoundary || !profileFlow?.connectDeepLink) { + throw new Error('Packaged desktop local/remote/API profile flow failed'); + } + mvpFlowProof = { + connectDiscovery: true, + localProfile: profileFlow.local, + remoteActiveProfile: profileFlow.active && profileFlow.remote, + lifecycleBoundary: profileFlow.lifecycleBoundary, + connectUiPopulated: profileFlow.connectDeepLink, + }; + await closePackagedProfileEditorAndWaitForWelcomeChooser(window); + } + } else if (packagedSmokeTest) { + await assertPackagedMvpBoundary(); } if (packagedSmokeTest && !connectJourney) { log('info', 'desktop.renderer.mvp_flows.ready', mvpFlowProof); @@ -1123,6 +1352,8 @@ const createMainWindow = async ( }); } log('info', 'desktop.renderer.ready', { preloadBridgeExposed: true }); + nativeRendererReady = true; + maybeCompleteNativeFirstLaunch(); return window; }; @@ -1141,6 +1372,7 @@ if (!hasSingleInstanceLock) { if (shutdownStarted) return; const deepLink = deepLinkFromArguments(argv); if (deepLink) deliverDeepLink(deepLink); + else recordNativeRejectedArguments(argv); if (mainWindow) { if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.show(); @@ -1155,6 +1387,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'); + } const transportSmoke = packagedTransportSmoke(); activePackagedTransportSmoke = transportSmoke; const connectSmoke = packagedConnectSmoke(); @@ -1272,6 +1514,32 @@ if (!hasSingleInstanceLock) { retryPending: credentialInitialization.retryPending, }); } + if (nativeSmokePhase) { + const current = await profiles.list(); + const expected = new Map([ + ['native-local', 'http://localhost:44221'], + ['native-tunnel', 'https://t-preserved.propr.dev'], + ]); + if (nativeSmokePhase === 'first') { + if (current.activeProfileId !== null || current.profiles.length !== 0) { + throw new Error('Native first launch did not start with an isolated profile'); + } + await profiles.save({ + id: 'native-local', + label: 'Preserved local profile', + apiBaseUrl: expected.get('native-local')!, + }); + await profiles.save({ + id: 'native-tunnel', + label: 'Native ProPR Connect tunnel', + apiBaseUrl: expected.get('native-tunnel')!, + }); + await profiles.setActive('native-local'); + } else if (current.activeProfileId !== 'native-local' || current.profiles.length !== expected.size + || current.profiles.some(profile => expected.get(profile.id) !== profile.apiBaseUrl)) { + throw new Error('Native relaunch did not preserve the non-secret profile state exactly'); + } + } if (app.isPackaged && !rendererPolicyPinnedForSmoke) { const current = await credentials.listProfiles(); const activeOrigin = current.profiles @@ -1279,6 +1547,7 @@ if (!hasSingleInstanceLock) { rendererPolicyOrigins = activeOrigin?.startsWith('http://') ? [activeOrigin] : []; } const lifecycle = new LocalLifecycleController(); + nativeProfiles = profiles; const registeredIpc = registerIpcHandlers({ app, ipcMain, @@ -1291,6 +1560,8 @@ if (!hasSingleInstanceLock) { devServerUrl, packagedRendererUrl, openExternal: openAllowedExternalUrl, + acknowledgeDeepLink: (event, acknowledgement) => + deepLinkDelivery.acknowledgeSender(event.sender, acknowledgement), ...(app.isPackaged && !rendererPolicyPinnedForSmoke ? { onRendererActiveProfileChanged: (origin: string | null) => { const nextOrigins = origin?.startsWith('http://') ? [origin] : []; @@ -1321,6 +1592,7 @@ if (!hasSingleInstanceLock) { const shutdown = createDesktopShutdownCoordinator({ credentials, lifecycle: shutdownLifecycle, + deepLinks: deepLinkDelivery, ipc: registeredIpc, profiles, sessionSecurity, @@ -1362,7 +1634,7 @@ if (!hasSingleInstanceLock) { log('info', 'desktop.app.shutdown_retry_requested'); app.quit(); } - } else if (packagedSmokeTest) { + } else if (packagedSmokeTest && nativeSmokePhase !== 'first') { app.quit(); } else { mainWindow.show(); diff --git a/apps/desktop/src/preload-bridge.test.ts b/apps/desktop/src/preload-bridge.test.ts index 1da9cfa34..adba0cd54 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); } } @@ -85,27 +85,57 @@ 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']); unsubscribe(); assert.equal(ipc.listeners.has(IPC_CHANNELS.deepLink), true); }); - it('buffers startup and second-instance deep links until the renderer subscribes', () => { + it('buffers startup and second-instance deep links until the renderer subscribes', async () => { const ipc = new FakeIpc(); const bridge = createDesktopBridge(ipc); 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', ]); + await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual(ipc.invocations, [ + { + channel: IPC_CHANNELS.deepLinkAcknowledgement, + args: [{ + deliveryId: 1, + url: 'propr://connect?api=http%3A%2F%2Flocalhost%3A4000', + consumption: { kind: 'connect-confirmation', target: 'http://localhost:4000' }, + }], + }, + { + channel: IPC_CHANNELS.deepLinkAcknowledgement, + args: [{ + deliveryId: 2, + url: 'propr://open?path=%2Ftasks', + consumption: { kind: 'open-queued', target: '/tasks' }, + }], + }, + ]); }); }); diff --git a/apps/desktop/src/preload-bridge.ts b/apps/desktop/src/preload-bridge.ts index 3a6e6e3d3..aa1feed1c 100644 --- a/apps/desktop/src/preload-bridge.ts +++ b/apps/desktop/src/preload-bridge.ts @@ -1,10 +1,15 @@ -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 => @@ -17,14 +22,41 @@ export const createDesktopBridge = ( || process.platform === 'win32', connectJourneyAcceptance = false, ): 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 isConsumption = (value: unknown): value is DesktopDeepLinkConsumption => Boolean( + value && typeof value === 'object' + && ['connect-confirmation', 'open-queued', 'open-navigated'].includes( + (value as DesktopDeepLinkConsumption).kind, + ) + && typeof (value as DesktopDeepLinkConsumption).target === 'string' + && (value as DesktopDeepLinkConsumption).target.length > 0 + && (value as DesktopDeepLinkConsumption).target.length <= 2_048, + ); + const consume = (delivery: DesktopDeepLinkDelivery): void => { + const acknowledgements = [...deepLinkListeners] + .map(listener => listener(delivery.url)) + .filter(isConsumption); + 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 = { @@ -32,7 +64,7 @@ export const createDesktopBridge = ( 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/release-workflow.test.ts b/apps/desktop/src/release-workflow.test.ts index bf0a28a03..dcd8e6b59 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,34 @@ 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.match(validation, /run-packaged-darwin-connect-smoke\.sh[\s\S]*native-lifecycle/); + 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 evidence 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.${evidence}`)); + assert.match(nativeArtifactLifecycle, /inspectArtifactArchitecture/); + assert.match(nativeArtifactLifecycle, /assertSafeExtractedTree/); + assert.match(nativeArtifactLifecycle, /assertProfileAuthority/); + 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/); + }); + test('keeps both Windows architectures and the complete machine-scope installer contract mandatory', () => { for (const [jobName, section] of [ diff --git a/apps/desktop/src/shared/contract.ts b/apps/desktop/src/shared/contract.ts index 2fe16b87e..709743c2d 100644 --- a/apps/desktop/src/shared/contract.ts +++ b/apps/desktop/src/shared/contract.ts @@ -22,9 +22,24 @@ export const IPC_CHANNELS = Object.freeze({ lifecycleStop: 'desktop:lifecycle-stop', lifecycleRestart: 'desktop:lifecycle-restart', deepLink: 'desktop:deep-link', + deepLinkAcknowledgement: 'desktop:deep-link-acknowledgement', acceptanceJourneyStage: 'desktop:acceptance-journey-stage', } 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 DesktopAcceptanceJourneyStage = | 'AUTHENTICATION_REQUIRED' | 'CREDENTIAL_COMMITTED' @@ -113,7 +128,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/shutdown.ts b/apps/desktop/src/shutdown.ts index 7ca53e081..af34afaa8 100644 --- a/apps/desktop/src/shutdown.ts +++ b/apps/desktop/src/shutdown.ts @@ -13,6 +13,7 @@ interface ShutdownOptions { credentials: { dispose(): Promise }; lifecycle: { shutdown(): Promise }; ipc: RegisteredIpcHandlers; + deepLinks?: { close(): void; whenIdle(): Promise }; profiles: { close(): Promise }; sessionSecurity: { close(): void; dispose(): void }; disposeRendererProtocol(): void; @@ -74,6 +75,8 @@ export const createDesktopShutdownCoordinator = ( state = 'draining'; options.onStarted(); step('admission-closed'); + options.deepLinks?.close(); + if (options.deepLinks) step('deep-links-closed'); options.ipc.close(); step('ipc-closed'); options.sessionSecurity.close(); @@ -87,10 +90,13 @@ export const createDesktopShutdownCoordinator = ( step('lifecycle-drain-started'); const ipcDrain = options.ipc.awaitIdle(); step('ipc-drain-started'); + const deepLinkDrain = options.deepLinks?.whenIdle() ?? Promise.resolve(); + if (options.deepLinks) step('deep-link-drain-started'); completion = bounded(Promise.allSettled([ credentialDrain, lifecycleDrain, ipcDrain, + deepLinkDrain, ]).then(results => { for (const result of results) if (result.status === 'rejected') throw result.reason; }), 'service-drain').then(async () => { 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 49b9cc89f..80c212a51 100644 --- a/apps/desktop/src/smoke-test-authorization.test.ts +++ b/apps/desktop/src/smoke-test-authorization.test.ts @@ -116,7 +116,7 @@ describe('packaged smoke profile authorization', () => { 'utf8', ); const isolation = main.indexOf("app.setPath('userData', packagedSmokeUserDataDirectory)"); - const sink = main.indexOf('createPackagedSmokeEvidenceSink(packagedSmokeUserDataDirectory)'); + const sink = main.indexOf('createPackagedSmokeEvidenceSink(packagedSmokeUserDataDirectory, nativeSmokePhase)'); const authorized = main.indexOf("packagedSmokeEvidence?.write('desktop.smoke.authorized')"); const appReady = main.indexOf("log('info', 'desktop.app.ready'"); const shutdownCoordinator = main.indexOf('const shutdown = createDesktopShutdownCoordinator({'); 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..627c1560f 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,32 @@ export const PACKAGED_SMOKE_EVIDENCE_EVENTS = [ 'desktop.log.write_failed', ] 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', + '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 +61,14 @@ 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 evidencePath = join( + authorizedUserDataDirectory, + nativePhase ? NATIVE_SMOKE_EVIDENCE_FILES[nativePhase] : PACKAGED_SMOKE_EVIDENCE_FILE, + ); const descriptor = openSync(evidencePath, 'wx', 0o600); let closed = false; const emitted = new Set(); diff --git a/propr-ui/src/desktop-deep-link.ts b/propr-ui/src/desktop-deep-link.ts index a81070ed7..5caa9570f 100644 --- a/propr-ui/src/desktop-deep-link.ts +++ b/propr-ui/src/desktop-deep-link.ts @@ -1,4 +1,5 @@ import { dashboardPathFromDeepLink } from '../../apps/desktop/src/security'; +import type { DesktopDeepLinkConsumption } from './desktop/types'; const validProfileId = (value: string): boolean => value.length > 0 && value.length <= 128 && !/[\u0000-\u001F\u007F]/.test(value); @@ -7,6 +8,11 @@ interface PendingNavigation { profileId: string; } +export interface DesktopDeepLinkNavigationResult { + path: string; + state: 'queued' | 'navigated'; +} + /** Holds accepted routes while binding each one to the profile active when it arrived. */ export class DesktopDeepLinkNavigation { private activeProfileId: string | null = null; @@ -17,19 +23,27 @@ export class DesktopDeepLinkNavigation { private readonly reject: () => void = () => undefined, ) {} - receive(value: string, profileId: string): boolean { + receiveWithState(value: string, profileId: string): DesktopDeepLinkNavigationResult | null { const path = dashboardPathFromDeepLink(value); if (!path || !validProfileId(profileId)) { this.reject(); - return false; + return null; + } + if (this.activeProfileId === profileId) { + this.navigate(path); + return { path, state: 'navigated' }; + } else if (this.activeProfileId === null) { + this.pending.push({ path, profileId }); + return { path, state: 'queued' }; } - if (this.activeProfileId === profileId) this.navigate(path); - else if (this.activeProfileId === null) this.pending.push({ path, profileId }); else { this.reject(); - return false; + return null; } - return true; + } + + receive(value: string, profileId: string): boolean { + return this.receiveWithState(value, profileId) !== null; } setDashboardReady(profileId: string): void { @@ -56,15 +70,16 @@ export class DesktopDeepLinkNavigation { /** One-consumer handoff between the desktop bridge and presentation experience. */ export class DesktopDeepLinkInbox { - private listener: ((value: string) => void) | null = null; + private listener: ((value: string) => DesktopDeepLinkConsumption | null) | null = null; private readonly pending: string[] = []; - receive(value: string): void { - if (this.listener) this.listener(value); - else this.pending.push(value); + receive(value: string): DesktopDeepLinkConsumption | null { + if (this.listener) return this.listener(value); + this.pending.push(value); + return null; } - subscribe(listener: (value: string) => void): () => void { + subscribe(listener: (value: string) => DesktopDeepLinkConsumption | null): () => void { if (this.listener) throw new Error('Desktop deep-link inbox already has a consumer'); this.listener = listener; this.pending.splice(0).forEach(value => listener(value)); diff --git a/propr-ui/src/desktop/DesktopExperience.test.tsx b/propr-ui/src/desktop/DesktopExperience.test.tsx index f5545dcb8..044b43d91 100644 --- a/propr-ui/src/desktop/DesktopExperience.test.tsx +++ b/propr-ui/src/desktop/DesktopExperience.test.tsx @@ -66,9 +66,16 @@ describe('DesktopExperience', () => { expect(await screen.findByRole('heading', { name: 'Let’s set up this computer' })).toBeInTheDocument(); vi.clearAllMocks(); - act(() => deepLinks.receive('propr://connect?api=https%3A%2F%2Fconnect.propr.dev')); + let consumption: ReturnType = null; + act(() => { + consumption = deepLinks.receive('propr://connect?api=https%3A%2F%2Fconnect.propr.dev'); + }); expect(await screen.findByRole('status')).toHaveTextContent(/untrusted instance address/i); + expect(consumption).toEqual({ + kind: 'connect-confirmation', + target: 'https://connect.propr.dev', + }); expect(screen.getByLabelText('Instance URL')).toHaveValue('https://connect.propr.dev'); expect(screen.getByRole('button', { name: 'Connect' })).toBeInTheDocument(); expect(adapters.discovery.discover).not.toHaveBeenCalled(); @@ -119,9 +126,13 @@ describe('DesktopExperience', () => { render(
Connected app
); expect(await screen.findByText('Connected app')).toBeInTheDocument(); - act(() => deepLinks.receive('propr://open?path=%2Ftasks%3Fstatus%3Dopen')); + let consumption: ReturnType = null; + act(() => { + consumption = deepLinks.receive('propr://open?path=%2Ftasks%3Fstatus%3Dopen'); + }); expect(window.location.hash).toBe('#/tasks?status=open'); + expect(consumption).toEqual({ kind: 'open-navigated', target: '/tasks?status=open' }); expect(screen.queryByLabelText('Instance URL')).not.toBeInTheDocument(); }); @@ -132,11 +143,15 @@ describe('DesktopExperience', () => { expect(await screen.findByRole('heading', { name: 'Let’s set up this computer' })).toBeInTheDocument(); vi.clearAllMocks(); - act(() => deepLinks.receive('propr://connect?api=SENTINEL_ATTACKER_VALUE&token=secret')); + let consumption: ReturnType = null; + act(() => { + consumption = deepLinks.receive('propr://connect?api=SENTINEL_ATTACKER_VALUE&token=secret'); + }); const alert = await screen.findByRole('alert'); expect(alert).toHaveTextContent('ProPR Desktop could not use that link. Choose an instance and try again.'); expect(alert).not.toHaveTextContent('SENTINEL_ATTACKER_VALUE'); + expect(consumption).toBeNull(); expect(adapters.connection.probe).not.toHaveBeenCalled(); expect(adapters.authentication.authenticate).not.toHaveBeenCalled(); expect(adapters.profiles.save).not.toHaveBeenCalled(); diff --git a/propr-ui/src/desktop/types.ts b/propr-ui/src/desktop/types.ts index c6b3f3cbe..0689b4079 100644 --- a/propr-ui/src/desktop/types.ts +++ b/propr-ui/src/desktop/types.ts @@ -1,5 +1,10 @@ export type DesktopPlatform = 'macos' | 'windows' | 'linux'; +export type DesktopDeepLinkConsumption = { + kind: 'connect-confirmation' | 'open-queued' | 'open-navigated'; + target: string; +}; + export interface DesktopProfile { id: string; name: string; @@ -91,7 +96,7 @@ export interface DesktopManagedTunnelRecoveryAdapter { export interface DesktopAdapters { platform: DesktopPlatform; app: { - onDeepLink(listener: (url: string) => void): () => void; + onDeepLink(listener: (url: string) => DesktopDeepLinkConsumption | null): () => void; }; profiles: DesktopProfileAdapter; discovery: DesktopDiscoveryAdapter; diff --git a/propr-ui/src/desktop/useDesktopDeepLinks.ts b/propr-ui/src/desktop/useDesktopDeepLinks.ts index 0e8a7a745..cbf84ec2f 100644 --- a/propr-ui/src/desktop/useDesktopDeepLinks.ts +++ b/propr-ui/src/desktop/useDesktopDeepLinks.ts @@ -1,9 +1,9 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import type { RefObject } from 'react'; import { isProprLoopbackHostname } from '@propr/shared'; -import { connectApiBaseUrlFromDeepLink } from '../../../apps/desktop/src/security'; +import { connectApiBaseUrlFromDeepLink, dashboardPathFromDeepLink } from '../../../apps/desktop/src/security'; import { DesktopDeepLinkNavigation, type DesktopDeepLinkInbox } from '../desktop-deep-link'; -import type { DesktopProfile } from './types'; +import type { DesktopDeepLinkConsumption, DesktopProfile } from './types'; const REJECTED_DEEP_LINK_MESSAGE = 'ProPR Desktop could not use that link. Choose an instance and try again.'; const CONNECT_CANDIDATE_NOTICE = 'Review this untrusted instance address, then choose Connect to continue.'; @@ -55,7 +55,7 @@ export const useDesktopDeepLinks = ({ }, () => setDeepLinkError(REJECTED_DEEP_LINK_MESSAGE), )); - const handler = useRef<(value: string) => void>(() => undefined); + const handler = useRef<(value: string) => DesktopDeepLinkConsumption | null>(() => null); handler.current = value => { let action: string | null = null; @@ -70,7 +70,7 @@ export const useDesktopDeepLinks = ({ const baseUrl = connectApiBaseUrlFromDeepLink(value); if (!baseUrl) { setDeepLinkError(REJECTED_DEEP_LINK_MESSAGE); - return; + return null; } const candidate: DesktopProfile = { id: createProfileId(), @@ -82,26 +82,44 @@ export const useDesktopDeepLinks = ({ setDeepLinkError(null); setEditorNotice(CONNECT_CANDIDATE_NOTICE); stageCandidateRef.current(candidate, phaseRef.current); - return; + return { kind: 'connect-confirmation', target: baseUrl }; } if (action === 'open') { const currentPhase = phaseRef.current; const currentProfileId = profileIdRef.current; if (currentPhase === 'loading') { + const path = dashboardPathFromDeepLink(value); + if (!path) { + setDeepLinkError(REJECTED_DEEP_LINK_MESSAGE); + return null; + } startupOpenLinks.current.push(value); - return; + return { kind: 'open-queued', target: path }; } - if ((currentPhase === 'connecting' || currentPhase === 'connected') && currentProfileId) { - if (activeProfileId.current !== currentProfileId - || !navigation.receive(value, currentProfileId)) { + const boundProfileId = currentPhase === 'connecting' || currentPhase === 'connected' + ? currentProfileId + : activeProfileId.current; + if (boundProfileId) { + if ((currentPhase === 'connecting' || currentPhase === 'connected') + && activeProfileId.current !== boundProfileId) { + setDeepLinkError(REJECTED_DEEP_LINK_MESSAGE); + return null; + } + const result = navigation.receiveWithState(value, boundProfileId); + if (!result) { setDeepLinkError(REJECTED_DEEP_LINK_MESSAGE); + return null; } - return; + return { + kind: result.state === 'queued' ? 'open-queued' : 'open-navigated', + target: result.path, + }; } } setDeepLinkError(REJECTED_DEEP_LINK_MESSAGE); + return null; }; useEffect(() => deepLinks?.subscribe(value => handler.current(value)), [deepLinks]); From 45e8ce34401653c2a29b3be0cc8715c66b59823d Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 10:24:12 +0000 Subject: [PATCH 2/4] feat(ai): Implemented both corrections without weakening native assertions. Implemented both corrections without weakening native assertions. - Linux failure root cause: `safeStorage.encryptString` exited because the native artifact intentionally lacked a Secret Service session. Native CI now launches under an isolated unlocked D-Bus/gnome-libsecret session and verifies actual credential round-trip/deletion plus the exact `gnome_libsecret` backend. Diagnostics add a fixed, secret-safe `SECURE_STORAGE_BACKEND` milestone. - F1: synchronous renderer send exceptions now clear active/timer state and settle `whenIdle()`. - F2: buffered renderer deliveries preserve their eventual consumption result through preload and ACK only after the UI consumes them. - F3: one ACK timeout no longer discards other accepted queued links. Validation passed: - Focused desktop: 63/63 - Focused renderer: 29/29 - Full desktop: 524 passed, 25 skipped - Full renderer: 626 passed - Desktop/UI typecheck - Linux x64 package build - `git diff --check` No visual preview was created because the changes are nonvisual. No commit, push, or PR-state change was made; the PR remains targeted to the desktop epic. The real Linux/macOS native matrix must run after the system publishes these worktree edits, since GitHub Actions cannot test an uncommitted tree. PR: #2125 Comment by: @integry (ID: 5558427717) Model: gpt-5.6-sol --- .github/workflows/desktop-release-guard.yml | 19 +++-- apps/desktop/README.md | 4 +- .../test-native-artifact-lifecycle.mjs | 81 +++++++++++++++---- .../test-native-artifact-lifecycle.test.mjs | 33 ++++++++ apps/desktop/src/deep-link-delivery.test.ts | 68 ++++++++++++++++ apps/desktop/src/deep-link-delivery.ts | 4 +- apps/desktop/src/main.ts | 21 ++--- apps/desktop/src/preload-bridge.ts | 18 +++-- apps/desktop/src/release-workflow.test.ts | 7 +- apps/desktop/src/shared/contract.ts | 4 +- apps/desktop/src/smoke-test-evidence.ts | 2 +- propr-ui/src/desktop-deep-link.test.ts | 13 ++- propr-ui/src/desktop-deep-link.ts | 12 +-- .../src/desktop/DesktopExperience.test.tsx | 40 +++++++++ propr-ui/src/desktop/types.ts | 4 +- 15 files changed, 273 insertions(+), 57 deletions(-) diff --git a/.github/workflows/desktop-release-guard.yml b/.github/workflows/desktop-release-guard.yml index d6b8f1e25..7483a90a4 100644 --- a/.github/workflows/desktop-release-guard.yml +++ b/.github/workflows/desktop-release-guard.yml @@ -320,12 +320,19 @@ jobs: 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 }}" + keyring_root="$(mktemp -d)" + trap 'rm -rf -- "$keyring_root"' EXIT + dbus-run-session -- bash -euo pipefail -c ' + export XDG_DATA_HOME="$1" + eval "$(printf "%s\n" "propr-native-lifecycle" | gnome-keyring-daemon --unlock --components=secrets)" + xvfb-run --auto-servernum \ + node apps/desktop/scripts/test-native-artifact-lifecycle.mjs \ + --version "$2" \ + --platform linux \ + --arch "$3" \ + --artifact-directory "$4" + ' bash "$keyring_root" "$PROPR_DESKTOP_VERSION" "${{ matrix.arch }}" \ + "desktop-release-${{ matrix.platform }}-${{ matrix.arch }}" else bash apps/desktop/scripts/run-packaged-darwin-connect-smoke.sh \ "${{ matrix.arch }}" native-lifecycle "$PROPR_DESKTOP_VERSION" \ diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 640aa6b45..43cdcbaf6 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -194,8 +194,8 @@ code-signing identity in an isolated keychain. It signs the copied app (never th designated requirement before and after both launches, and restores the runner's original keychain list/default before deleting the identity and temporary keychain. This stabilizes the Safe Storage application identity without changing trust settings and is not evidence of Developer ID signing, notarization, Gatekeeper approval, or end-user launchability. -Linux intentionally withholds the outer session bus from the artifact process: it proves plaintext/basic-text fallback -is refused, but does not claim libsecret custody. Cold launches are direct argv; Linux package warm dispatch uses an +Linux runs each artifact against one isolated, unlocked D-Bus/libsecret session and proves credential round-trip and +deletion without permitting plaintext/basic-text fallback. Cold launches are direct argv; Linux package warm dispatch uses an isolated XDG MIME database and `gio`, ZIP warm dispatch is direct because ZIP has no registered launcher, and macOS warm dispatch uses LaunchServices against the exact copied bundle. diff --git a/apps/desktop/scripts/test-native-artifact-lifecycle.mjs b/apps/desktop/scripts/test-native-artifact-lifecycle.mjs index 950e15175..e7659a00f 100644 --- a/apps/desktop/scripts/test-native-artifact-lifecycle.mjs +++ b/apps/desktop/scripts/test-native-artifact-lifecycle.mjs @@ -40,6 +40,7 @@ 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 DBUS_SESSION_ADDRESS = /^unix:path=\/[^\0\r\n,]+(?:,guid=[0-9a-f]{32})?$/; 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'; @@ -95,6 +96,23 @@ export const parseArguments = args => { return { platform, arch, version, artifactDirectory: resolve(artifactDirectory) }; }; +export const createNativeLaunchContext = ({ platform, baseEnvironment, sessionAddress }) => { + if (platform !== 'linux') { + return Object.freeze({ + environment: Object.freeze({ ...baseEnvironment }), + arguments: Object.freeze([]), + }); + } + if (typeof sessionAddress !== 'string' || sessionAddress.length > 4096 || /[\0\r\n]/.test(sessionAddress) + || !DBUS_SESSION_ADDRESS.test(sessionAddress)) { + throw new Error('Native Linux lifecycle requires one validated D-Bus session address'); + } + return Object.freeze({ + environment: Object.freeze({ ...baseEnvironment, DBUS_SESSION_BUS_ADDRESS: sessionAddress }), + arguments: Object.freeze(['--disable-gpu', '--password-store=gnome-libsecret']), + }); +}; + const appendBounded = (current, chunk) => { const next = Buffer.concat([current, Buffer.from(chunk)]); return next.length <= OUTPUT_CAP ? next : next.subarray(next.length - OUTPUT_CAP); @@ -188,6 +206,7 @@ export const FIRST_EVIDENCE_MILESTONES = Object.freeze([ 'NO_EVIDENCE', 'AUTHORIZED', 'IDENTITY', + 'SECURE_STORAGE_BACKEND', 'DEEP_LINK_DELIVERY_FAILURE', 'COLD_ACK', 'SECURE_STORAGE_STARTED', @@ -841,10 +860,10 @@ const startApplication = (application, args, env, cwd, processGroups) => process stdio: ['ignore', 'ignore', 'ignore'], })); -const dispatchDirect = async (application, userData, link, env, processGroups) => { +const dispatchDirect = async (application, userData, link, env, processGroups, launchArguments = []) => { const group = startApplication( application, - [`--user-data-dir=${userData}`, link], + [...launchArguments, `--user-data-dir=${userData}`, link], env, dirname(application.applicationRoot), processGroups, @@ -861,7 +880,10 @@ const linuxProtocolDispatch = async ({ application, profile, link, env, processG 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`); + const relocated = source.replace( + /^Exec=.*$/m, + `Exec=${application.executable} --disable-gpu --password-store=gnome-libsecret --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 }); @@ -1210,6 +1232,7 @@ export const classifyFirstEvidenceFailure = async (path, resultClass) => { 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.native.secure_storage_backend_invalid')) milestone = 'SECURE_STORAGE_BACKEND'; 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'; @@ -1299,19 +1322,25 @@ const lifecycleForArtifact = async ({ target, kind, artifact, report }) => { profileApiUrl: profileApi.url, preserveMacosKeychainContext: target.platform === 'darwin', }); + const launchContext = createNativeLaunchContext({ + platform: target.platform, + baseEnvironment, + sessionAddress: process.env.DBUS_SESSION_BUS_ADDRESS, + }); const firstEnvironment = Object.freeze({ - ...baseEnvironment, + ...launchContext.environment, 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 }; + const dispatchEnvironment = { ...launchContext.environment }; delete dispatchEnvironment.PROPR_DESKTOP_SMOKE_TEST; delete dispatchEnvironment.PROPR_DESKTOP_SMOKE_PROFILE_API_URL; operationStage = 'FIRST_LAUNCH'; const first = startApplication(application, [ + ...launchContext.arguments, '--propr-smoke-test', `--user-data-dir=${profile.userData}`, COLD_MANUAL, @@ -1328,7 +1357,14 @@ const lifecycleForArtifact = async ({ target, kind, artifact, report }) => { throw error; } operationStage = 'WARM_MANUAL_DISPATCH'; - await dispatchDirect(application, profile.userData, WARM_MANUAL, dispatchEnvironment, processGroups); + await dispatchDirect( + application, + profile.userData, + WARM_MANUAL, + dispatchEnvironment, + processGroups, + launchContext.arguments, + ); operationStage = 'WARM_MANUAL_EVIDENCE'; await waitForEvents(firstEvidence, ['desktop.deeplink.warm_manual_once'], first.child); if (target.platform === 'darwin') { @@ -1354,11 +1390,25 @@ const lifecycleForArtifact = async ({ target, kind, artifact, report }) => { 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); + await dispatchDirect( + application, + profile.userData, + WARM_OPEN, + dispatchEnvironment, + processGroups, + launchContext.arguments, + ); 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); + await dispatchDirect( + application, + profile.userData, + 'native-evidence-malformed', + dispatchEnvironment, + processGroups, + launchContext.arguments, + ); operationStage = 'MALFORMED_EVIDENCE'; await waitForEvents(firstEvidence, ['desktop.deeplink.rejected_malformed'], first.child); operationStage = 'OVERSIZED_DISPATCH'; @@ -1368,6 +1418,7 @@ const lifecycleForArtifact = async ({ target, kind, artifact, report }) => { `propr://connect?api=https%3A%2F%2Ft-native-evidence.propr.dev%2F${'a'.repeat(2_100)}`, dispatchEnvironment, processGroups, + launchContext.arguments, ); operationStage = 'OVERSIZED_EVIDENCE'; await waitForEvents(firstEvidence, ['desktop.deeplink.rejected_oversized'], first.child); @@ -1378,23 +1429,20 @@ const lifecycleForArtifact = async ({ target, kind, artifact, report }) => { 'https://native-evidence.invalid/unsafe', dispatchEnvironment, processGroups, + launchContext.arguments, ); 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_probe.completed' - ? ['desktop.native.secure_storage_fallback_refused', event] - : [event]) - : REQUIRED_FIRST_EVENTS; + const requiredFirstEvents = REQUIRED_FIRST_EVENTS; operationStage = 'FIRST_EVIDENCE_VALIDATION'; await waitForEvents(firstEvidence, requiredFirstEvents, { exitCode: null }); await assertEvidenceOrdering(firstEvidence, requiredFirstEvents); await assertProfileAuthority(profile); const relaunchEnvironment = Object.freeze({ - ...baseEnvironment, + ...launchContext.environment, PROPR_DESKTOP_NATIVE_ARTIFACT_PHASE: 'relaunch', PROPR_DESKTOP_NATIVE_EXPECTED_ARCH: target.arch, PROPR_DESKTOP_NATIVE_EXPECTED_PLATFORM: target.platform, @@ -1402,6 +1450,7 @@ const lifecycleForArtifact = async ({ target, kind, artifact, report }) => { }); operationStage = 'RELAUNCH'; const relaunch = startApplication(application, [ + ...launchContext.arguments, '--propr-smoke-test', `--user-data-dir=${profile.userData}`, COLD_TUNNEL, @@ -1436,7 +1485,7 @@ const lifecycleForArtifact = async ({ target, kind, artifact, report }) => { lifecycle: 'extract-or-mount-copy/launch/shutdown/relaunch/remove', protocol, secureStorage: target.platform === 'linux' - ? 'fallback-only; plaintext refused; libsecret custody not exercised' + ? 'isolated gnome-libsecret round-trip and deletion' : 'OS-protected Keychain round-trip and deletion', }); } catch (error) { @@ -1493,7 +1542,7 @@ export const runNativeArtifactLifecycle = async target => { target: `${target.platform}-${target.arch}`, evidence: report, limitations: target.platform === 'linux' - ? '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. ZIP warm dispatch is direct. Package warm dispatch uses isolated XDG/GIO. Secure storage uses one isolated gnome-libsecret session.' : '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 581dd4f0f..974093a29 100644 --- a/apps/desktop/scripts/test-native-artifact-lifecycle.test.mjs +++ b/apps/desktop/scripts/test-native-artifact-lifecycle.test.mjs @@ -10,6 +10,7 @@ import { assertSafeExtractedTree, classifyFirstEvidenceFailure, closeProfileApi, + createNativeLaunchContext, DmgMountAuthority, extractDmg, extractRpm, @@ -58,6 +59,33 @@ describe('native staged artifact lifecycle authority', () => { ]) assert.throws(() => parseArguments(args), /invalid|missing|duplicated|malformed/); }); + test('binds Linux launches to one validated libsecret session without disabling the sandbox', () => { + const baseEnvironment = Object.freeze({ + HOME: '/private/profile/home', + PROPR_DESKTOP_SMOKE_TEST: '1', + }); + const sessionAddress = 'unix:path=/run/user/1000/bus,guid=0123456789abcdef0123456789abcdef'; + const linux = createNativeLaunchContext({ + platform: 'linux', + baseEnvironment, + sessionAddress, + }); + assert.deepEqual(linux, { + environment: { ...baseEnvironment, DBUS_SESSION_BUS_ADDRESS: sessionAddress }, + arguments: ['--disable-gpu', '--password-store=gnome-libsecret'], + }); + assert.throws(() => createNativeLaunchContext({ + platform: 'linux', + baseEnvironment, + sessionAddress: 'tcp:host=attacker.invalid', + }), /validated D-Bus session/); + assert.deepEqual(createNativeLaunchContext({ + platform: 'darwin', + baseEnvironment, + sessionAddress: undefined, + }), { environment: baseEnvironment, arguments: [] }); + }); + 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 }; @@ -254,6 +282,11 @@ describe('native staged artifact lifecycle authority', () => { { 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.native.secure_storage_backend_invalid', + milestone: 'SECURE_STORAGE_BACKEND', + stage: 'FIRST_INITIAL_EVIDENCE', + }, { event: 'desktop.deeplink.delivery_failed', milestone: 'DEEP_LINK_DELIVERY_FAILURE', diff --git a/apps/desktop/src/deep-link-delivery.test.ts b/apps/desktop/src/deep-link-delivery.test.ts index fe2a7079c..b8eb3f21f 100644 --- a/apps/desktop/src/deep-link-delivery.test.ts +++ b/apps/desktop/src/deep-link-delivery.test.ts @@ -119,6 +119,74 @@ describe('desktop deep-link delivery', () => { assert.match(failure?.message ?? '', /acknowledgement deadline/); }); + it('reports a synchronous send failure and settles idle before accepting later work', async () => { + const sent: DesktopDeepLinkDelivery[] = []; + const failures: Error[] = []; + let failNextSend = true; + const window: DeepLinkWindow = { + isDestroyed: () => false, + webContents: { + isLoading: () => false, + send: (_channel, value) => { + if (failNextSend) { + failNextSend = false; + throw new Error('window destroyed during send'); + } + sent.push(value); + }, + }, + }; + const delivery = new DeepLinkDelivery( + 'desktop:deep-link', + [], + undefined, + error => { failures.push(error); }, + ); + delivery.setWindow(window); + + assert.equal(delivery.deliver('propr://open?path=%2Ftasks'), true); + await delivery.whenIdle(); + assert.deepEqual(failures.map(error => error.message), ['window destroyed during send']); + + assert.equal(delivery.deliver('propr://open?path=%2Fplans'), true); + assert.equal(sent.length, 1); + assert.equal(delivery.acknowledge(window, { + ...sent[0], + consumption: { kind: 'open-queued', target: '/plans' }, + }), true); + await delivery.whenIdle(); + }); + + it('continues with accepted queued links after one acknowledgement timeout', async () => { + const sent: DesktopDeepLinkDelivery[] = []; + const consumed: string[] = []; + const failures: Error[] = []; + const window = createWindow(sent); + const delivery = new DeepLinkDelivery( + 'desktop:deep-link', + [], + value => { consumed.push(value); }, + error => { failures.push(error); }, + Date.now, + 1_000, + 20, + ); + delivery.setWindow(window); + + assert.equal(delivery.deliver('propr://open?path=%2Ftasks'), true); + assert.equal(delivery.deliver('propr://open?path=%2Fplans'), true); + while (sent.length < 2) await tick(); + assert.equal(delivery.acknowledge(window, { + ...sent[1], + consumption: { kind: 'open-queued', target: '/plans' }, + }), true); + await delivery.whenIdle(); + + assert.equal(failures.length, 1); + assert.match(failures[0].message, /acknowledgement deadline/); + assert.deepEqual(consumed, ['propr://open?path=%2Fplans']); + }); + it('cancels pending acknowledgement work during coordinated shutdown', async () => { const sent: DesktopDeepLinkDelivery[] = []; let failed = false; diff --git a/apps/desktop/src/deep-link-delivery.ts b/apps/desktop/src/deep-link-delivery.ts index 19ee85169..1d67ab928 100644 --- a/apps/desktop/src/deep-link-delivery.ts +++ b/apps/desktop/src/deep-link-delivery.ts @@ -151,16 +151,14 @@ export class DeepLinkDelivery { timer, window, }; - window.webContents.send(this.channel, delivery); try { + window.webContents.send(this.channel, delivery); const consumption = await acknowledgement; await this.delivered(value, consumption, window); } catch (error) { - this.pending.splice(0); if (!this.closed) { this.failed(error instanceof Error ? error : new Error('Desktop renderer deep-link acknowledgement failed')); } - return; } finally { clearTimeout(timer); this.active = null; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index ced737de0..cef9216c0 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -357,13 +357,7 @@ const runNativeSecureStorageProbe = async (): Promise => { token: `propr_it_${'a'.repeat(43)}`, }; const credentialWrite = await nativeProfiles.writeCredential(credential); - if (process.platform === 'linux') { - if (storage.available || credentialWrite.stored - || await nativeProfiles.readCredential('native-local') !== null) { - 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.available) { const stored = await nativeProfiles.readCredential('native-local'); if (storage.backend === 'basic_text' || !credentialWrite.stored || stored?.token !== credential.token || stored.origin !== credential.origin) { @@ -385,8 +379,9 @@ const runNativeSecureStorageProbe = async (): Promise => { } else if (credentialWrite.stored || await nativeProfiles.readCredential('native-local') !== null) { 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'); + const expectedBackend = process.platform === 'linux' ? 'gnome_libsecret' : 'os-protected'; + if (!storage.available || storage.backend !== expectedBackend) { + throw new Error('Native artifact did not retain its expected secure-storage custody'); } recordNativeEvent('desktop.native.secure_storage_probe.completed'); recordNativeEvent('desktop.native.secure_storage_enforced'); @@ -1444,6 +1439,14 @@ if (!hasSingleInstanceLock) { decrypt: value => safeStorage.decryptString(value), }; const profiles = new ProfileStore(app.getPath('userData'), productionEncryption); + if (nativeSmokePhase) { + const security = profiles.security(); + const expectedBackend = process.platform === 'linux' ? 'gnome_libsecret' : 'os-protected'; + if (!security.available || security.backend !== expectedBackend) { + recordNativeEvent('desktop.native.secure_storage_backend_invalid'); + throw new Error('Native artifact secure-storage backend is unavailable'); + } + } const connectDiscovery = new DesktopConnectDiscoveryService(profiles, { supported: DESKTOP_CONNECT_DISCOVERY_PLATFORMS.has(process.platform), discover: async () => { diff --git a/apps/desktop/src/preload-bridge.ts b/apps/desktop/src/preload-bridge.ts index aa1feed1c..761266d95 100644 --- a/apps/desktop/src/preload-bridge.ts +++ b/apps/desktop/src/preload-bridge.ts @@ -22,7 +22,9 @@ export const createDesktopBridge = ( || process.platform === 'win32', connectJourneyAcceptance = false, ): DesktopBridge => { - const deepLinkListeners = new Set<(url: string) => DesktopDeepLinkConsumption | null>(); + const deepLinkListeners = new Set<(url: string) => ( + DesktopDeepLinkConsumption | null | Promise + )>(); const pendingDeepLinks: DesktopDeepLinkDelivery[] = []; const isDelivery = (value: unknown): value is DesktopDeepLinkDelivery => Boolean( value && typeof value === 'object' @@ -39,16 +41,16 @@ export const createDesktopBridge = ( && (value as DesktopDeepLinkConsumption).target.length > 0 && (value as DesktopDeepLinkConsumption).target.length <= 2_048, ); - const consume = (delivery: DesktopDeepLinkDelivery): void => { - const acknowledgements = [...deepLinkListeners] - .map(listener => listener(delivery.url)) - .filter(isConsumption); + const consume = async (delivery: DesktopDeepLinkDelivery): Promise => { + const acknowledgements = (await Promise.all( + [...deepLinkListeners].map(listener => listener(delivery.url)), + )).filter(isConsumption); if (acknowledgements.length !== 1) return; const acknowledgement: DesktopDeepLinkAcknowledgement = { ...delivery, consumption: acknowledgements[0], }; - void invoke(ipc, IPC_CHANNELS.deepLinkAcknowledgement, acknowledgement).catch(() => undefined); + await invoke(ipc, IPC_CHANNELS.deepLinkAcknowledgement, acknowledgement).catch(() => undefined); }; ipc.on(IPC_CHANNELS.deepLink, (_event, value) => { if (!isDelivery(value)) return; @@ -56,7 +58,7 @@ export const createDesktopBridge = ( pendingDeepLinks.push(value); return; } - consume(value); + void consume(value).catch(() => undefined); }); const bridge: DesktopBridge = { @@ -64,7 +66,7 @@ export const createDesktopBridge = ( getMetadata: () => invoke(ipc, IPC_CHANNELS.appMetadata), onDeepLink: (listener) => { deepLinkListeners.add(listener); - pendingDeepLinks.splice(0).forEach(consume); + pendingDeepLinks.splice(0).forEach(delivery => { void consume(delivery).catch(() => undefined); }); return () => deepLinkListeners.delete(listener); }, }, diff --git a/apps/desktop/src/release-workflow.test.ts b/apps/desktop/src/release-workflow.test.ts index dcd8e6b59..a795f1389 100644 --- a/apps/desktop/src/release-workflow.test.ts +++ b/apps/desktop/src/release-workflow.test.ts @@ -328,9 +328,12 @@ describe('desktop trusted release workflow', () => { 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.match(validation, /keyring_root="\$\(mktemp -d\)"/); + assert.match(validation, /gnome-keyring-daemon --unlock --components=secrets/); + assert.match(validation, /dbus-run-session -- bash -euo pipefail -c '[\s\S]*xvfb-run --auto-servernum/); assert.match(validation, /run-packaged-darwin-connect-smoke\.sh[\s\S]*native-lifecycle/); - assert.match(validation, /--artifact-directory "desktop-release-\$\{\{ matrix\.platform \}\}-\$\{\{ matrix\.arch \}\}"/); + assert.match(validation, /--artifact-directory "\$4"/); + assert.match(validation, /"desktop-release-\$\{\{ matrix\.platform \}\}-\$\{\{ matrix\.arch \}\}"/); assert.match(forgeConfig, /mimeType: \['x-scheme-handler\/propr'\]/); for (const kind of ['deb', 'rpm', 'zip', 'dmg']) { diff --git a/apps/desktop/src/shared/contract.ts b/apps/desktop/src/shared/contract.ts index 709743c2d..bae42ba6e 100644 --- a/apps/desktop/src/shared/contract.ts +++ b/apps/desktop/src/shared/contract.ts @@ -128,7 +128,9 @@ export type LocalLifecycleOperationResult = export interface DesktopBridge { app: { getMetadata(): Promise; - onDeepLink(listener: (url: string) => DesktopDeepLinkConsumption | null): () => void; + onDeepLink(listener: ( + url: string, + ) => DesktopDeepLinkConsumption | null | Promise): () => 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 627c1560f..1e5081a78 100644 --- a/apps/desktop/src/smoke-test-evidence.ts +++ b/apps/desktop/src/smoke-test-evidence.ts @@ -30,10 +30,10 @@ export const PACKAGED_SMOKE_EVIDENCE_EVENTS = [ export const NATIVE_SMOKE_EVIDENCE_EVENTS = [ 'desktop.deeplink.delivery_failed', 'desktop.native.identity_verified', + 'desktop.native.secure_storage_backend_invalid', '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', diff --git a/propr-ui/src/desktop-deep-link.test.ts b/propr-ui/src/desktop-deep-link.test.ts index 29ab7ec85..c97238acb 100644 --- a/propr-ui/src/desktop-deep-link.test.ts +++ b/propr-ui/src/desktop-deep-link.test.ts @@ -94,14 +94,21 @@ describe('desktop open deep-link navigation', () => { }); describe('desktop deep-link inbox', () => { - it('delivers values received before a consumer subscribes exactly once', () => { + it('delivers values received before a consumer subscribes exactly once with eventual consumption', async () => { const inbox = new DesktopDeepLinkInbox(); const first = vi.fn(); const second = vi.fn(); - inbox.receive('propr://connect?api=https%3A%2F%2Ffirst.example'); + const eventualConsumption = inbox.receive('propr://connect?api=https%3A%2F%2Ffirst.example'); - const unsubscribe = inbox.subscribe(first); + const unsubscribe = inbox.subscribe(value => { + first(value); + return { kind: 'connect-confirmation', target: 'https://first.example' }; + }); expect(first).toHaveBeenCalledOnce(); + await expect(eventualConsumption).resolves.toEqual({ + kind: 'connect-confirmation', + target: 'https://first.example', + }); unsubscribe(); const unsubscribeSecond = inbox.subscribe(second); expect(second).not.toHaveBeenCalled(); diff --git a/propr-ui/src/desktop-deep-link.ts b/propr-ui/src/desktop-deep-link.ts index 5caa9570f..3cd0a2ab1 100644 --- a/propr-ui/src/desktop-deep-link.ts +++ b/propr-ui/src/desktop-deep-link.ts @@ -71,18 +71,20 @@ export class DesktopDeepLinkNavigation { /** One-consumer handoff between the desktop bridge and presentation experience. */ export class DesktopDeepLinkInbox { private listener: ((value: string) => DesktopDeepLinkConsumption | null) | null = null; - private readonly pending: string[] = []; + private readonly pending: Array<{ + resolve: (consumption: DesktopDeepLinkConsumption | null) => void; + value: string; + }> = []; - receive(value: string): DesktopDeepLinkConsumption | null { + receive(value: string): DesktopDeepLinkConsumption | null | Promise { if (this.listener) return this.listener(value); - this.pending.push(value); - return null; + return new Promise(resolve => this.pending.push({ resolve, value })); } subscribe(listener: (value: string) => DesktopDeepLinkConsumption | null): () => void { if (this.listener) throw new Error('Desktop deep-link inbox already has a consumer'); this.listener = listener; - this.pending.splice(0).forEach(value => listener(value)); + this.pending.splice(0).forEach(({ resolve, value }) => resolve(listener(value))); return () => { if (this.listener === listener) this.listener = null; }; diff --git a/propr-ui/src/desktop/DesktopExperience.test.tsx b/propr-ui/src/desktop/DesktopExperience.test.tsx index 044b43d91..a7b42fefc 100644 --- a/propr-ui/src/desktop/DesktopExperience.test.tsx +++ b/propr-ui/src/desktop/DesktopExperience.test.tsx @@ -1,5 +1,7 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createDesktopBridge, type PreloadIpc } from '../../../apps/desktop/src/preload-bridge'; +import { IPC_CHANNELS } from '../../../apps/desktop/src/shared/contract'; import { DesktopDeepLinkInbox } from '../desktop-deep-link'; import { DesktopExperience } from './DesktopExperience'; import { adaptersFor, deferred, localProfile, remoteProfile, renderConnectedExperience } from './DesktopExperience.testSupport'; @@ -94,6 +96,44 @@ describe('DesktopExperience', () => { expect(adapters.connection.activate).toHaveBeenCalledOnce(); }); + it('acknowledges a preload delivery after the inbox and hook consume buffered work', async () => { + const invocations: Array<{ channel: string; args: unknown[] }> = []; + let receiveFromMain: ((event: unknown, value: unknown) => void) | undefined; + const ipc: PreloadIpc = { + invoke: async (channel, ...args) => { invocations.push({ channel, args }); }, + on: (channel, listener) => { + if (channel === IPC_CHANNELS.deepLink) receiveFromMain = listener; + }, + removeListener: () => undefined, + }; + const bridge = createDesktopBridge(ipc); + const delivery = { + deliveryId: 41, + url: 'propr://connect?api=https%3A%2F%2Fconnect.propr.dev', + }; + receiveFromMain?.({}, delivery); + + const inbox = new DesktopDeepLinkInbox(); + const unsubscribe = bridge.app.onDeepLink(value => inbox.receive(value)); + const adapters = adaptersFor(); + const rendered = render( +
Shared route tree
+ ); + try { + expect(await screen.findByLabelText('Instance URL')).toHaveValue('https://connect.propr.dev'); + await waitFor(() => expect(invocations).toEqual([{ + channel: IPC_CHANNELS.deepLinkAcknowledgement, + args: [{ + ...delivery, + consumption: { kind: 'connect-confirmation', target: 'https://connect.propr.dev' }, + }], + }])); + } finally { + unsubscribe(); + rendered.unmount(); + } + }); + it('returns from the prefilled profile editor to every packaged-layout chooser element', async () => { const adapters = adaptersFor(); const deepLinks = new DesktopDeepLinkInbox(); diff --git a/propr-ui/src/desktop/types.ts b/propr-ui/src/desktop/types.ts index 0689b4079..54a95231b 100644 --- a/propr-ui/src/desktop/types.ts +++ b/propr-ui/src/desktop/types.ts @@ -96,7 +96,9 @@ export interface DesktopManagedTunnelRecoveryAdapter { export interface DesktopAdapters { platform: DesktopPlatform; app: { - onDeepLink(listener: (url: string) => DesktopDeepLinkConsumption | null): () => void; + onDeepLink(listener: ( + url: string, + ) => DesktopDeepLinkConsumption | null | Promise): () => void; }; profiles: DesktopProfileAdapter; discovery: DesktopDiscoveryAdapter; From 73f32ea76ec33e594128cc2710ddb1812d154f0f Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 10:44:08 +0000 Subject: [PATCH 3/4] feat(ai): Implemented F4 only. Implemented F4 only. - Shutdown now closes deep-link admission without discarding active or queued deliveries. - Deep-link acknowledgement IPC remains available during bounded shutdown draining; other IPC admission still closes. - Added coverage for active and pending links acknowledging after shutdown begins. Validation: - Desktop typecheck passed. - Focused tests: 26 passed. - Full desktop suite: 525 passed, 25 skipped. - `git diff --check` passed. No visual preview was created because this is non-visual lifecycle behavior. Changes remain uncommitted as requested. PR: #2125 Comment by: @integry (ID: 5558656471) Model: gpt-5.6-sol --- apps/desktop/src/deep-link-delivery.test.ts | 30 ++++-- apps/desktop/src/deep-link-delivery.ts | 9 +- apps/desktop/src/ipc-lifecycle.test.ts | 104 +++++++++++++++++++- apps/desktop/src/ipc.ts | 9 +- 4 files changed, 130 insertions(+), 22 deletions(-) diff --git a/apps/desktop/src/deep-link-delivery.test.ts b/apps/desktop/src/deep-link-delivery.test.ts index b8eb3f21f..a37167dcd 100644 --- a/apps/desktop/src/deep-link-delivery.test.ts +++ b/apps/desktop/src/deep-link-delivery.test.ts @@ -187,29 +187,41 @@ describe('desktop deep-link delivery', () => { assert.deepEqual(consumed, ['propr://open?path=%2Fplans']); }); - it('cancels pending acknowledgement work during coordinated shutdown', async () => { + it('closes admission while draining active and pending acknowledgements during shutdown', async () => { const sent: DesktopDeepLinkDelivery[] = []; - let failed = false; + const consumed: string[] = []; + const failures: Error[] = []; const delivery = new DeepLinkDelivery( 'desktop:deep-link', [], - undefined, - () => { failed = true; }, + value => { consumed.push(value); }, + error => { failures.push(error); }, ); const window = createWindow(sent); delivery.setWindow(window); assert.equal(delivery.deliver('propr://open?path=%2Ftasks'), true); + assert.equal(delivery.deliver('propr://open?path=%2Fplans'), true); assert.equal(sent.length, 1); delivery.close(); - await delivery.whenIdle(); - - assert.equal(failed, false); - assert.equal(delivery.deliver('propr://open?path=%2Fplans'), false); + assert.equal(delivery.deliver('propr://open?path=%2Finbox'), false); assert.equal(delivery.acknowledge(window, { ...sent[0], consumption: { kind: 'open-queued', target: '/tasks' }, - }), false); + }), true); + await tick(); + assert.equal(sent.length, 2); + assert.equal(delivery.acknowledge(window, { + ...sent[1], + consumption: { kind: 'open-queued', target: '/plans' }, + }), true); + await delivery.whenIdle(); + + assert.deepEqual(consumed, [ + 'propr://open?path=%2Ftasks', + 'propr://open?path=%2Fplans', + ]); + assert.deepEqual(failures, []); }); it('deduplicates a cold link reported through argv and open-url before delivery', async () => { diff --git a/apps/desktop/src/deep-link-delivery.ts b/apps/desktop/src/deep-link-delivery.ts index 1d67ab928..ffaa130fc 100644 --- a/apps/desktop/src/deep-link-delivery.ts +++ b/apps/desktop/src/deep-link-delivery.ts @@ -32,7 +32,6 @@ export class DeepLinkDelivery { acknowledged: boolean; delivery: DesktopDeepLinkDelivery; resolve: (consumption: DesktopDeepLinkConsumption) => void; - reject: (error: Error) => void; timer: ReturnType; window: TWindow; } | null = null; @@ -108,15 +107,10 @@ export class DeepLinkDelivery { return new Promise(resolve => this.idleWaiters.add(resolve)); } + /** Closes admission without canceling deliveries accepted before shutdown. */ close(): void { if (this.closed) return; this.closed = true; - this.pending.splice(0); - this.active?.reject(new Error('Desktop deep-link delivery closed during shutdown')); - if (!this.draining && !this.active) { - this.idleWaiters.forEach(resolve => resolve()); - this.idleWaiters.clear(); - } } private flush(_window: TWindow): void { @@ -147,7 +141,6 @@ export class DeepLinkDelivery { acknowledged: false, delivery, resolve: resolveAcknowledgement, - reject: rejectAcknowledgement, timer, window, }; diff --git a/apps/desktop/src/ipc-lifecycle.test.ts b/apps/desktop/src/ipc-lifecycle.test.ts index b57a334ab..9b5ff04cf 100644 --- a/apps/desktop/src/ipc-lifecycle.test.ts +++ b/apps/desktop/src/ipc-lifecycle.test.ts @@ -2,12 +2,13 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import type { App, IpcMain, IpcMainInvokeEvent, Session } from 'electron'; import type { DesktopCredentialService } from './credential-service'; +import { DeepLinkDelivery, type DeepLinkWindow } from './deep-link-delivery'; import { registerIpcHandlers } from './ipc'; import type { LocalLifecycleController } from './lifecycle'; import type { DesktopLogger } from './logger'; import type { ProfileStore } from './profile-store'; import { rendererContentSecurityPolicy } from './security'; -import { IPC_CHANNELS } from './shared/contract'; +import { IPC_CHANNELS, type DesktopDeepLinkDelivery } from './shared/contract'; import { createDesktopShutdownCoordinator } from './shutdown'; const deferred = () => { @@ -714,7 +715,7 @@ describe('desktop IPC shutdown gate', () => { assert.equal(removalCommitted, false); }); - it('replaces every handler with a fixed closing failure and drains admitted work before disposal', async () => { + it('closes new work admission and drains admitted work before disposal', async () => { const handlers = new Map unknown>(); const ipcMain = { handle: (channel: string, handler: (...args: any[]) => unknown) => { handlers.set(channel, handler); }, @@ -768,6 +769,105 @@ describe('desktop IPC shutdown gate', () => { assert.equal(handlers.size, 0); }); + it('keeps active and pending deep-link acknowledgement IPC open during shutdown drain', async () => { + const handlers = new Map unknown>(); + const ipcMain = { + handle: (channel: string, handler: (...args: any[]) => unknown) => { handlers.set(channel, handler); }, + removeHandler: (channel: string) => { handlers.delete(channel); }, + } as unknown as IpcMain; + const sent: DesktopDeepLinkDelivery[] = []; + const consumed: string[] = []; + const deepLinks = new DeepLinkDelivery( + IPC_CHANNELS.deepLink, + [], + value => { consumed.push(value); }, + ); + const sender = { + isLoading: () => false, + send: (_channel: string, delivery: DesktopDeepLinkDelivery) => { sent.push(delivery); }, + }; + const window: DeepLinkWindow = { + isDestroyed: () => false, + webContents: sender, + }; + deepLinks.setWindow(window); + + let profileListCalls = 0; + const registered = registerIpcHandlers({ + app: { + getName: () => 'ProPR', getVersion: () => '0.8.15', isPackaged: true, + } as unknown as App, + ipcMain, + profiles: {} as ProfileStore, + credentials: { + listProfiles: async () => { + profileListCalls += 1; + return { profiles: [], activeProfileId: null }; + }, + } as unknown as DesktopCredentialService, + connectDiscovery, + lifecycle: {} as LocalLifecycleController, + logger: { log: () => undefined } as unknown as DesktopLogger, + desktopSession: {} as Session, + devServerUrl: undefined, + packagedRendererUrl: 'propr-renderer://app/index.html', + openExternal: async () => undefined, + acknowledgeDeepLink: (event, acknowledgement) => + deepLinks.acknowledgeSender(event.sender, acknowledgement), + }); + const event = { + sender, + senderFrame: { url: 'propr-renderer://app/index.html' }, + } as unknown as IpcMainInvokeEvent; + const invoke = (channel: string, ...args: unknown[]) => + Promise.resolve(handlers.get(channel)!(event, ...args)); + + assert.equal(deepLinks.deliver('propr://open?path=%2Ftasks'), true); + assert.equal(deepLinks.deliver('propr://open?path=%2Fplans'), true); + assert.equal(sent.length, 1); + + let windowDestroyed = false; + const shutdown = createDesktopShutdownCoordinator({ + credentials: { dispose: async () => undefined }, + lifecycle: { shutdown: async () => undefined }, + deepLinks, + ipc: registered, + profiles: { close: async () => undefined }, + sessionSecurity: { close: () => undefined, dispose: () => undefined }, + disposeRendererProtocol: () => undefined, + getWindow: () => ({ + isDestroyed: () => windowDestroyed, + destroy: () => { windowDestroyed = true; }, + }), + quit: () => undefined, + onStarted: () => undefined, + log: () => undefined, + }); + shutdown.beforeQuit({ preventDefault: () => undefined }); + + assert.equal(deepLinks.deliver('propr://open?path=%2Finbox'), false); + await assert.rejects(invoke(IPC_CHANNELS.profilesList), /DESKTOP_CLOSING/); + assert.equal(profileListCalls, 0); + await invoke(IPC_CHANNELS.deepLinkAcknowledgement, { + ...sent[0], + consumption: { kind: 'open-queued', target: '/tasks' }, + }); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(sent.length, 2); + await invoke(IPC_CHANNELS.deepLinkAcknowledgement, { + ...sent[1], + consumption: { kind: 'open-queued', target: '/plans' }, + }); + await shutdown.awaitFinished(); + + assert.deepEqual(consumed, [ + 'propr://open?path=%2Ftasks', + 'propr://open?path=%2Fplans', + ]); + assert.equal(windowDestroyed, true); + assert.equal(handlers.size, 0); + }); + for (const category of ['profile', 'pairing', 'session'] as const) { it(`runs an admitted ${category} handler through the production before-quit drain`, async () => { const handlers = new Map unknown>(); diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index e9fa6c498..29d961cd3 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -118,10 +118,10 @@ export const registerIpcHandlers = (options: RegisterIpcOptions): RegisteredIpcH const senderUrl = event.senderFrame?.url ?? ''; return isTrustedRendererUrl(senderUrl, options.devServerUrl, options.packagedRendererUrl); }; - const handle = (channel: string, handler: Handler): void => { + const handle = (channel: string, handler: Handler, completesAdmittedWork = false): void => { channels.add(channel); options.ipcMain.handle(channel, async (event, ...args) => { - if (closing) throw closingError(); + if (closing && !completesAdmittedWork) throw closingError(); if (!trusted(event)) { options.logger.log('warn', 'desktop.ipc.rejected', { channel }); throw new Error('Untrusted desktop IPC sender'); @@ -178,7 +178,7 @@ export const registerIpcHandlers = (options: RegisterIpcOptions): RegisteredIpcH if (!options.acknowledgeDeepLink?.(event, acknowledgement)) { throw new Error('Unexpected desktop deep-link acknowledgement'); } - }); + }, true); 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'); @@ -275,6 +275,9 @@ export const registerIpcHandlers = (options: RegisterIpcOptions): RegisteredIpcH if (closing) return; closing = true; for (const channel of channels) { + // This channel completes deep links accepted before admission closed. + // Final disposal removes it after the bounded shutdown drain. + if (channel === IPC_CHANNELS.deepLinkAcknowledgement) continue; options.ipcMain.removeHandler(channel); options.ipcMain.handle(channel, () => Promise.reject(closingError())); } From 595d192378a3ac309c81abdae72f3d354b3386c2 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 11:07:30 +0000 Subject: [PATCH 4/4] feat(ai): Implemented the bounded F5/F6 correction without touching S1/S2 or Windows work. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented the bounded F5/F6 correction without touching S1/S2 or Windows work. - F5: DMG attachment now acquires tentative mount authority before `hdiutil attach`. Failed attaches query the exact mount root and retain authority until absence is proven. [lifecycle harness](/tmp/git-processor/worktrees/integry/propr/pr-2125-followup-2026-09-06T10-56-38/apps/desktop/scripts/test-native-artifact-lifecycle.mjs:693) - F6: LaunchServices registration now becomes tentative before `lsregister -f`, ensuring failure paths unregister and prove the exact copied bundle absent before removal. [lifecycle harness](/tmp/git-processor/worktrees/integry/propr/pr-2125-followup-2026-09-06T10-56-38/apps/desktop/scripts/test-native-artifact-lifecycle.mjs:932) - Added focused injected partial-success coverage for both paths. [tests](/tmp/git-processor/worktrees/integry/propr/pr-2125-followup-2026-09-06T10-56-38/apps/desktop/scripts/test-native-artifact-lifecycle.test.mjs:239) - Added fixed, secret-free failure categories for the Intel failure boundary—startup failure, cold-confirmation inspection/visibility, renderer loss, or uncaught exception. Required ACK and secure-storage evidence remain unchanged. [classification](/tmp/git-processor/worktrees/integry/propr/pr-2125-followup-2026-09-06T10-56-38/apps/desktop/scripts/test-native-artifact-lifecycle.mjs:1259) Intel CI diagnosis: [job 101472991876](https://github.com/integry/propr/actions/runs/34028244853/job/101472991876) proves the child acknowledged the cold link, then exited before secure-storage probing or renderer-ready evidence. The retained diagnostics discard whether confirmation inspection failed, the UI remained invisible, or the renderer exited, so a narrower root cause cannot responsibly be claimed. A job-only rerun was attempted, but GitHub rejected it as non-rerunnable; I did not rerun the entire workflow because that would include deferred Windows jobs. Validation: - Focused lifecycle authority suite: 23 passed. - Full desktop suite: 527 passed, 25 skipped. - Desktop and renderer typecheck: passed. - `git diff --check`: passed. No visual preview was created because the changes are lifecycle cleanup and diagnostics only. Changes remain uncommitted and the PR was not merged, as requested. PR: #2125 Comment by: @integry (ID: 5558752928) Model: gpt-5.6-sol --- .../test-native-artifact-lifecycle.mjs | 57 ++++++++++-- .../test-native-artifact-lifecycle.test.mjs | 91 +++++++++++++++++++ apps/desktop/src/main.ts | 31 ++++--- apps/desktop/src/smoke-test-evidence.test.ts | 12 +++ apps/desktop/src/smoke-test-evidence.ts | 3 + 5 files changed, 176 insertions(+), 18 deletions(-) diff --git a/apps/desktop/scripts/test-native-artifact-lifecycle.mjs b/apps/desktop/scripts/test-native-artifact-lifecycle.mjs index e7659a00f..80fdba4e1 100644 --- a/apps/desktop/scripts/test-native-artifact-lifecycle.mjs +++ b/apps/desktop/scripts/test-native-artifact-lifecycle.mjs @@ -214,6 +214,14 @@ export const FIRST_EVIDENCE_MILESTONES = Object.freeze([ 'RENDERER', ]); +export const FIRST_EVIDENCE_FAILURE_CATEGORIES = Object.freeze([ + 'START_FAILED', + 'UNCAUGHT_EXCEPTION', + 'COLD_CONFIRMATION_INSPECTION_FAILED', + 'COLD_CONFIRMATION_NOT_VISIBLE', + 'RENDERER_GONE', +]); + export class NativeLifecycleEvidenceWaitFailure extends Error { constructor(resultClass) { if (!NATIVE_LIFECYCLE_EVIDENCE_RESULT_CLASSES.includes(resultClass)) { @@ -234,11 +242,17 @@ export class NativeLifecycleOperationFailure extends Error { } if (evidenceClassification && (!FIRST_EVIDENCE_MILESTONES.includes(evidenceClassification.milestone) - || !NATIVE_LIFECYCLE_EVIDENCE_RESULT_CLASSES.includes(evidenceClassification.resultClass))) { + || !NATIVE_LIFECYCLE_EVIDENCE_RESULT_CLASSES.includes(evidenceClassification.resultClass) + || (evidenceClassification.failureCategory !== undefined + && !FIRST_EVIDENCE_FAILURE_CATEGORIES.includes(evidenceClassification.failureCategory)))) { throw new Error('Native lifecycle evidence failure classification is invalid'); } const classification = evidenceClassification - ? ` [milestone:${evidenceClassification.milestone}] [result:${evidenceClassification.resultClass}]` + ? ` [milestone:${evidenceClassification.milestone}] [result:${evidenceClassification.resultClass}]${ + evidenceClassification.failureCategory + ? ` [category:${evidenceClassification.failureCategory}]` + : '' + }` : ''; super(`Native lifecycle operation failed [stage:${stage}]${classification}`); this.name = 'NativeLifecycleOperationFailure'; @@ -246,6 +260,9 @@ export class NativeLifecycleOperationFailure extends Error { if (evidenceClassification) { this.milestone = evidenceClassification.milestone; this.resultClass = evidenceClassification.resultClass; + if (evidenceClassification.failureCategory) { + this.failureCategory = evidenceClassification.failureCategory; + } } Object.defineProperty(this, 'operationError', { value: operationError, enumerable: false }); } @@ -259,6 +276,7 @@ export class NativeLifecycleFailure extends AggregateError { ` [stage:${primaryError.stage}]`, ...(primaryError.milestone ? [` [milestone:${primaryError.milestone}]`] : []), ...(primaryError.resultClass ? [` [result:${primaryError.resultClass}]`] : []), + ...(primaryError.failureCategory ? [` [category:${primaryError.failureCategory}]`] : []), ].join('') : ''; const message = primaryError @@ -680,10 +698,23 @@ export class DmgMountAuthority { } async attach(artifact) { - await this.runCommand('/usr/bin/hdiutil', [ - 'attach', '-readonly', '-nobrowse', '-mountpoint', this.mountRoot, artifact, - ]); + // hdiutil can mount successfully before returning a failure or being killed. + // Hold tentative authority until an exact post-failure query proves absence. this.mounted = true; + try { + await this.runCommand('/usr/bin/hdiutil', [ + 'attach', '-readonly', '-nobrowse', '-mountpoint', this.mountRoot, artifact, + ]); + } catch (error) { + try { + const mounts = await this.runCommand('/usr/bin/hdiutil', ['info'], { timeout: 30_000 }); + if (!mountOutputContains(mounts.stdout, this.mountRoot)) this.mounted = false; + } catch { + // A failed query cannot release tentative mount authority. The caller's + // cleanup pass will retry detach plus the exact absence postcondition. + } + throw error; + } } async detach() { @@ -907,8 +938,10 @@ export class LaunchServicesAuthority { } async register() { - await this.runCommand(LAUNCH_SERVICES, ['-f', this.applicationRoot], { env: this.environment, timeout: 30_000 }); + // lsregister can update its database before returning a failure or timeout. + // Keep tentative authority so every exit path unregisters and proves absence. this.registered = true; + await this.runCommand(LAUNCH_SERVICES, ['-f', this.applicationRoot], { env: this.environment, timeout: 30_000 }); } async dispatch(link) { @@ -1228,6 +1261,7 @@ export const classifyFirstEvidenceFailure = async (path, resultClass) => { throw new Error('Native lifecycle evidence result class is invalid'); } let milestone = 'NO_EVIDENCE'; + let failureCategory; try { const events = new Set(await readFixedEvidenceEvents(path)); if (events.has('desktop.smoke.authorized')) milestone = 'AUTHORIZED'; @@ -1238,6 +1272,15 @@ export const classifyFirstEvidenceFailure = async (path, resultClass) => { 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'; + if (events.has('desktop.app.start_failed')) failureCategory = 'START_FAILED'; + if (events.has('desktop.main_process.uncaught_exception')) failureCategory = 'UNCAUGHT_EXCEPTION'; + if (events.has('desktop.native.cold_confirmation_inspection_failed')) { + failureCategory = 'COLD_CONFIRMATION_INSPECTION_FAILED'; + } + if (events.has('desktop.native.cold_confirmation_not_visible')) { + failureCategory = 'COLD_CONFIRMATION_NOT_VISIBLE'; + } + if (events.has('desktop.renderer.gone')) failureCategory = 'RENDERER_GONE'; } catch { // Only fixed classifications may cross the native-gate diagnostic boundary. } @@ -1246,7 +1289,7 @@ export const classifyFirstEvidenceFailure = async (path, resultClass) => { : ['SECURE_STORAGE_COMPLETED', 'RENDERER'].includes(milestone) ? 'FIRST_RENDERER_READY' : 'FIRST_INITIAL_EVIDENCE'; - return { milestone, resultClass, stage }; + return { milestone, resultClass, stage, ...(failureCategory ? { failureCategory } : {}) }; }; const lifecycleForArtifact = async ({ target, kind, artifact, report }) => { diff --git a/apps/desktop/scripts/test-native-artifact-lifecycle.test.mjs b/apps/desktop/scripts/test-native-artifact-lifecycle.test.mjs index 974093a29..388c2fb66 100644 --- a/apps/desktop/scripts/test-native-artifact-lifecycle.test.mjs +++ b/apps/desktop/scripts/test-native-artifact-lifecycle.test.mjs @@ -236,6 +236,35 @@ describe('native staged artifact lifecycle authority', () => { }); } + test('detaches and proves absence when attach fails after mounting the exact DMG root', async () => { + const calls = []; + let infoCalls = 0; + const runCommand = async (file, args) => { + calls.push([file, ...args]); + if (args[0] === 'attach') throw new Error('injected partial attach failure'); + if (args[0] === 'info') { + infoCalls += 1; + return { + stdout: Buffer.from(infoCalls === 1 ? '/dev/disk9 /private/mount\n' : ''), + stderr: Buffer.alloc(0), + }; + } + 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: async () => { throw new Error('scan must not run'); }, + runCommand, + }), /injected partial attach failure/); + + assert.equal(authority.mounted, false); + assert.deepEqual(calls.map(call => call[1]), ['attach', 'info', '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) => ({ @@ -314,6 +343,36 @@ describe('native staged artifact lifecycle authority', () => { }); } + const categories = [ + { event: 'desktop.app.start_failed', failureCategory: 'START_FAILED' }, + { event: 'desktop.main_process.uncaught_exception', failureCategory: 'UNCAUGHT_EXCEPTION' }, + { + event: 'desktop.native.cold_confirmation_inspection_failed', + failureCategory: 'COLD_CONFIRMATION_INSPECTION_FAILED', + }, + { + event: 'desktop.native.cold_confirmation_not_visible', + failureCategory: 'COLD_CONFIRMATION_NOT_VISIBLE', + }, + { event: 'desktop.renderer.gone', failureCategory: 'RENDERER_GONE' }, + ]; + for (const fixture of categories) { + await writeFile(evidence, [ + JSON.stringify({ event: 'desktop.deeplink.cold_manual_once' }), + JSON.stringify({ event: fixture.event }), + ].join('\n')); + assert.deepEqual(await classifyFirstEvidenceFailure(evidence, 'FAILED_EXIT'), { + milestone: 'COLD_ACK', + resultClass: 'FAILED_EXIT', + stage: 'FIRST_INITIAL_EVIDENCE', + failureCategory: fixture.failureCategory, + }); + } + + await writeFile(evidence, [ + JSON.stringify({ event: 'desktop.renderer.ready' }), + JSON.stringify({ event: 'desktop.renderer.gone' }), + ].join('\n')); const classification = await classifyFirstEvidenceFailure(evidence, 'FAILED_EXIT'); const operationFailure = new NativeLifecycleOperationFailure( classification.stage, @@ -327,6 +386,7 @@ describe('native staged artifact lifecycle authority', () => { assert.match(aggregate.message, /stage:FIRST_RENDERER_READY/); assert.match(aggregate.message, /milestone:RENDERER/); assert.match(aggregate.message, /result:FAILED_EXIT/); + assert.match(aggregate.message, /category:RENDERER_GONE/); 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/); @@ -381,6 +441,37 @@ describe('native staged artifact lifecycle authority', () => { }); }); + test('unregisters and proves absence when registration fails after partial success', async () => { + const applicationRoot = '/private/copied/ProPR Desktop.app'; + const calls = []; + const authority = new LaunchServicesAuthority(applicationRoot, { FIXED: 'environment' }, { + runCommand: async (file, args) => { + calls.push([file, ...args]); + if (args[0] === '-f') throw new Error('injected partial registration failure'); + return { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) }; + }, + }); + + await assert.rejects(authority.register(), /injected partial registration failure/); + assert.equal(authority.registered, true); + assert.deepEqual(await removeCopiedApplicationWithLaunchServicesAuthority({ + installRoot: '/private/install', + launchServices: authority, + }, { + removeInstallRoot: async () => { calls.push(['remove-install-root']); }, + assertInstallRootAbsent: async () => { calls.push(['install-postcondition']); }, + }), []); + + assert.equal(authority.registered, false); + assert.deepEqual(calls.map(call => call[1] ?? call[0]), [ + '-f', + '-u', + '-dump', + 'remove-install-root', + 'install-postcondition', + ]); + }); + 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/main.ts b/apps/desktop/src/main.ts index cef9216c0..0829a0fac 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1235,17 +1235,26 @@ const createMainWindow = async ( 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 input = Array.from(document.querySelectorAll('label')).find(label => - label.textContent?.includes('Instance URL'))?.querySelector('input'); - if (input?.value === ${JSON.stringify(expectedInitialApi)}) return true; - await new Promise(resolve => setTimeout(resolve, 25)); - } while (performance.now() < deadline); - return false; - })()`); - if (!initialEndpointVisible) throw new Error('Native cold deep link did not reach the confirmation UI'); + let initialEndpointVisible: unknown; + try { + initialEndpointVisible = await window.webContents.executeJavaScript(`(async () => { + const deadline = performance.now() + 2000; + do { + const input = Array.from(document.querySelectorAll('label')).find(label => + label.textContent?.includes('Instance URL'))?.querySelector('input'); + if (input?.value === ${JSON.stringify(expectedInitialApi)}) return true; + await new Promise(resolve => setTimeout(resolve, 25)); + } while (performance.now() < deadline); + return false; + })()`); + } catch (error) { + recordNativeEvent('desktop.native.cold_confirmation_inspection_failed'); + throw error; + } + if (!initialEndpointVisible) { + recordNativeEvent('desktop.native.cold_confirmation_not_visible'); + throw new Error('Native cold deep link did not reach the confirmation UI'); + } if (nativeSmokePhase === 'first') { await runNativeSecureStorageProbe(); recordNativeEvent('desktop.native.profile_fresh'); diff --git a/apps/desktop/src/smoke-test-evidence.test.ts b/apps/desktop/src/smoke-test-evidence.test.ts index 885552e16..39dbbf495 100644 --- a/apps/desktop/src/smoke-test-evidence.test.ts +++ b/apps/desktop/src/smoke-test-evidence.test.ts @@ -81,10 +81,22 @@ describe('packaged smoke evidence', () => { const relaunch = createPackagedSmokeEvidenceSink(directory, 'relaunch'); assert.ok(first && relaunch); first.write('desktop.native.profile_fresh'); + first.write('desktop.native.cold_confirmation_not_visible'); + first.write('desktop.renderer.gone'); + first.write('desktop.native.failure:/private/profile?credential=raw'); relaunch.write('desktop.native.profile_preserved'); first.close(); relaunch.close(); assert.deepEqual(readdirSync(directory).sort(), Object.values(NATIVE_SMOKE_EVIDENCE_FILES).sort()); + const firstRecords = readFileSync( + join(directory, NATIVE_SMOKE_EVIDENCE_FILES.first), + 'utf8', + ).trimEnd().split('\n').map(line => JSON.parse(line)); + assert.deepEqual(firstRecords, [ + { event: 'desktop.native.profile_fresh' }, + { event: 'desktop.native.cold_confirmation_not_visible' }, + { event: 'desktop.renderer.gone' }, + ]); }); }); }); diff --git a/apps/desktop/src/smoke-test-evidence.ts b/apps/desktop/src/smoke-test-evidence.ts index 1e5081a78..018fcb554 100644 --- a/apps/desktop/src/smoke-test-evidence.ts +++ b/apps/desktop/src/smoke-test-evidence.ts @@ -29,7 +29,10 @@ export const PACKAGED_SMOKE_EVIDENCE_EVENTS = [ export const NATIVE_SMOKE_EVIDENCE_EVENTS = [ 'desktop.deeplink.delivery_failed', + 'desktop.renderer.gone', 'desktop.native.identity_verified', + 'desktop.native.cold_confirmation_inspection_failed', + 'desktop.native.cold_confirmation_not_visible', 'desktop.native.secure_storage_backend_invalid', 'desktop.native.profile_fresh', 'desktop.native.profile_preserved',